« All Roles

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:

  1. 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.
  2. 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:

DocumentVoiceWhat it gives you
README.mdthe syllabuswhy the phase exists, concept map, lab specs, integrated-scenario ideas, deliverables checklist, key takeaways
WARMUP.mdthe professorzero-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.mdthe senior who's been therethe compressed practitioner tour — 30-second mental model, the numbers to tattoo on your arm, az/Terraform one-liners, war stories, beginner mistakes
BROTHER-TALK.mdyour brother, off the recordcandid real-talk — what nobody tells you, the 2 a.m. pager truth, the career framing
lab-*/the lab bencha 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

#PhaseYou build (flagship lab)
00The Azure Principal: Control Plane, Well-Architected & TradeoffsARM resource-id parser + Well-Architected five-pillar tradeoff scorer + SLA/cost/region modeler
01Azure Resource Manager & the Deployment EngineARM/Bicep template dependency-graph deployment engine + idempotent PUT + what-if diff
02Terraform, CDKTF & IaC State Internalsa Terraform-style state + plan engine: diff, ForceNew replace, dependency ordering, drift
03Microsoft Entra ID: OAuth2 / OIDC / JWTauthorization-code+PKCE & client-credentials flow engine + a JWT validator
04RBAC & Azure Policy Evaluationrole Actions/NotActions wildcard matcher + scope inheritance + deny assignments + Policy effects
05Azure Landing Zones & Management-Group Scalemanagement-group tree + effective policy/RBAC inheritance resolver + ALZ compliance scorer
06Azure Networking & the Software-Defined NetworkNSG priority evaluator + effective-routes longest-prefix engine + Private DNS / subnet calculator
07Containers, Docker, ACR & AKS Internalscontent-addressable OCI registry (digests, manifests, GC, geo-replication) + AKS bin-packing scheduler
08CI/CD Pipelines & Secure Supply Chainpipeline DAG executor (stages, gates, approvals) + OIDC workload-federation + blue-green/canary controller
09RESTful APIs, JSON & Secure AuthN/AuthZan API gateway: route matching + JWT scope authorization + token-bucket rate limiting + policy pipeline
10Event-Driven: Event Grid, Service Bus & Logic Appsa Service Bus broker (peek-lock, sessions, dedup, dead-letter) + Event Grid retry delivery
11Serverless: Functions, Triggers/Bindings & Durablea Functions scale controller + a Durable-Functions deterministic replay engine
12Secrets, Key Vault & Managed IdentityIMDS/MSI token acquisition + Key Vault envelope encryption + key rotation + soft-delete state machine
13Observability, Azure Monitor & KQLa KQL query engine (where/summarize/bin/join) + an alert-rule evaluator
14Reliability, HA & Operational TroubleshootingSLA composition + retry/backoff/jitter + circuit breaker + 429/Retry-After + a triage decision tree
15Principal Architecture, FinOps & Capstonean 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

  1. 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.
  2. For each phase: read WARMUP.md (professor), skim HITCHHIKERS-GUIDE.md (the numbers), do the lab (lab.py red → green against test_lab.py), then read BROTHER-TALK.md.
  3. Work phases roughly in order — identity (P03–P05) and networking (P06) underpin everything after them.
  4. 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):

  1. Provide troubleshooting and operational support for Azure cloud services and infrastructure.
  2. Design, implement, and maintain Infrastructure as Code (IaC) solutions using Terraform, HashiCorp CDKTF, and ARM Templates.
  3. Configure and manage Azure Landing Zones, Microsoft Entra ID (Azure AD), Azure Policies, RBAC, networking, and identity integrations.
  4. Build, deploy, and manage containerized applications using Docker and Azure Container Registry (ACR).
  5. Develop and maintain CI/CD pipelines to automate application builds, testing, and deployments.
  6. Develop and integrate RESTful APIs using JSON and implement secure authentication and authorization mechanisms (OAuth2, OpenID Connect).
  7. Design and implement event-driven architectures using Azure Event Grid, Azure Service Bus, and Azure Logic Apps.
  8. Integrate serverless applications with Azure security services, including Azure Key Vault, Managed Identities, and Microsoft Entra ID.
  9. Monitor, troubleshoot, and optimize cloud infrastructure to ensure high availability, security, and performance.
  10. 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 PUT against a resource provider, that the deployment engine builds a dependency graph from dependsOn and 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/scp claims 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)

FileContract
README.mdthe problem, what you build, key concepts table, file map, run commands, success criteria, "How this maps to real Azure", extensions, interview/resume bullets
lab.pylearner implementation with focused # TODO markers and signatures/docstrings already in place; never a blank file
solution.pycomplete reference; python solution.py runs a worked example and prints output; deterministic
test_lab.pypositive, negative, boundary, and determinism tests; runnable against either module via LAB_MODULE
requirements.txtusually 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:

DocumentVoiceWhat it gives you
README.mdthe syllabuswhy the phase exists, concept map, lab spec table, integrated-scenario ideas, deliverables checklist, key takeaways
WARMUP.mdthe professorzero-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.mdthe senior who's been therecompressed practitioner tour: 30-second mental model, the numbers to tattoo on your arm, az/Terraform one-liners, war stories, vocabulary, beginner mistakes
BROTHER-TALK.mdyour brother, off the recordcandid 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.md opens 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/DELETE to 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 resolves dependsOn + implicit reference()/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 modeIncremental (default; leaves un-templated resources) vs Complete (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 PUT is 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 azurerm provider 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 are ForceNew is 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 plan with 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 the code_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 (by kid), then check iss, aud, exp, nbf, and authorization claims (scp for delegated scopes, roles for app roles). → P03, P09
  • Claimsiss (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's kid selects 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.254 endpoint 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, and AssignableScopes. 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 authorizationActions govern managing the resource (ARM); DataActions govern the data inside it (e.g. read a blob, get a secret). A storage Owner is not automatically a Blob 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 field to 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, respecting limits, 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) or roles (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-limit and quota policies; returns 429 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_Id across 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 honor Retry-After and 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. OwnerBlob 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 VirtualNetworkVirtualNetwork 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 apply against Azure." — HCL → plan (state vs desired diff → create/update/replace/delete; ForceNew ⇒ replace), DAG ordering, each resource = an ARM PUT (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?"PUT desired 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 → issaudnbf/exp (±skew) → scp/roles. Skipping aud = the confused-deputy.
  • "Client credentials vs on-behalf-of vs managed identity — when?" — daemon vs middle- tier delegation vs secret-less Azure workload.
  • "scp vs roles?" — delegated (user present) vs application (daemon) authorization.

3. Authorization — RBAC & Policy (P04–P05)

  • "A user is Owner on the subscription but can't read a blob. Why?" — control-plane vs data-plane: Owner grants Actions, not DataActions; needs Storage 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 deny at 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."/23 per 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)

  1. Clarify the workload: RPS, payload size, latency SLO, availability target, data residency, compliance.
  2. Sketch control plane (identity, RBAC, policy, landing zone) and data plane (network, compute, events, storage) — principals always draw both.
  3. Quantify: SLA composition, cost/month, partition/instance sizing, retry budget.
  4. Name the tradeoff per Well-Architected pillar and write the ADR sentence.
  5. 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)

  1. Workload facts first: RPS, payload, p99 latency SLO, availability target, data residency, compliance regime, team size.
  2. 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.
  3. Quantify: compose the SLA (serial ∏Aᵢ; redundant 1−∏(1−Aᵢ)), estimate $/month, size instances/partitions, set the retry budget.
  4. Tradeoff per pillar: Reliability, Security, Cost, Operational Excellence, Performance — name what you trade and write the ADR sentence.
  5. 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 Corp MG; 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/0 via 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:

  1. There is exactly one control plane. Every write to Azure — a VM, a role assignment, a policy, a private endpoint — is an idempotent PUT to 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?"
  2. 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 PUT to a resource ID; ARM authenticates, authorizes (RBAC − NotActions, then deny wins), validates (Policy), then routes to the resource provider. Re-issuing the same PUT converges 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 storage Owner is not automatically a Blob 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 200 converts 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)

FieldValue
GoalBuild 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.
ConceptsARM 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."
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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.
  2. 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.
  3. 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).
  4. A private-networking hub-and-spoke — NSGs, UDRs, Azure Firewall, Private DNS (P06). Tradeoff: security isolation ↔ operability (the #1 Private Endpoint DNS failure).
  5. 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

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 PUT to a resource ID. Control plane (manage) and data plane (the blob/secret/message) are different systems with different identity, throttling, and SLA. OwnerBlob 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.py and your lab.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

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.
  • "PUT and POST are interchangeable." No — PUT is idempotent desired-state; POST is a non-idempotent action. Retrying a POST can 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 presentScope levelExample
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, not management.azure.com), with its own throttling, its own SLA, and its own authorization via RBAC DataActions.

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:

RoleControl 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/readYes

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:

  1. Reliability — does it stay up and recover? (SLA, redundancy, failover, retries, no single points of failure). Quantified by the math in Chapter 5.
  2. Security — is it safe? (identity as the perimeter, least privilege, encryption, network isolation, secrets in Key Vault, deny-by-default).
  3. Cost Optimization — is the spend justified? (right-sizing, reservations/savings plans, autoscale, scale-to-zero, lifecycle tiering, egress awareness — Chapter 7).
  4. 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).
  5. 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 helpsPillar it hurts
Multi-region active/activeReliability, PerformanceCost (2× compute + egress), Operational Excellence (more to run)
Private Endpoints everywhereSecurityOperational Excellence (DNS complexity — the #1 failure)
Aggressive autoscale-to-zeroCostPerformance (cold starts), Reliability (warm-up gaps)
Strict policy-as-code gatesSecurity, Operational Excellencevelocity (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 $$

AvailabilityDowntime / monthDowntime / 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):

  1. ResourceId + parse_resource_id (Ch. 1–2). Split on /, strip the leading slash, reject empty/odd segments, then read subscriptions/{s} → optional resourceGroups/{rg} → optional providers/{ns}/{type}/{name}[/{type2}/{name2}…]. For nested types, join the type segments with / and keep the leaf name. Raise ValueError on every malformed shape — interviewers will hand you a bad ID to see if you validate.
  2. resource_scope_level (Ch. 2). Parse, then classify by the deepest field set: provider → resource; resource_group → resourceGroup; else subscription.
  3. 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, two 0.990.9999) are the boundary probes.
  4. monthly_cost_usd (Ch. 7): three terms, all inputs non-negative.
  5. Architecture.__post_init__ (Ch. 4): all five pillars present, none unknown, each in [0, 1].
  6. resolve_tradeoff (Ch. 4): weight-normalised score per architecture; winner + deciding pillar; tie within tol. Make test_weights_flip_the_winner pass — 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:

  1. 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 / 429 came from.
  2. Explain why every ARM write is an idempotent PUT to a resource ID, and what idempotency buys IaC.
  3. Parse any resource ID into subscription / RG / provider / type / name (including a nested type) and name its scope and what inherits onto it.
  4. State why a storage Owner cannot read a blob with their Entra identity, and the fix.
  5. List the five WAF pillars and give a real tradeoff between two of them.
  6. Compose a serial and a redundant SLA in your head and convert it to minutes/month.
  7. Write the three-term cost model and name which term ambushes multi-region designs.
  8. 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

🛸 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 planeData plane
Endpointmanagement.azure.comstX.blob.core.windows.net
Authorized byRBAC ActionsRBAC DataActions
Example roleOwner (manage the account)Storage Blob Data Reader (read a blob)
OwnerBlob 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

ThingNumber
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 % serial0.999³ ≈ 99.7 % (≈3× the downtime)
Two 99 % redundant1 − 0.01² = 99.99 %
Serial SLA∏ Aᵢ (multiply — more hops, lower)
Redundant SLA1 − ∏(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 planup to ~72 % off steady-state compute
MG tree max depth6 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

  1. Saying "best architecture" without asking the workload's pillar weights.
  2. Thinking Owner can read data — forgetting the control/data-plane split.
  3. Quoting an SLA as the weakest link's number instead of the product of the serial path.
  4. Saying "99.9 % vs 99.99 %, basically the same" — it's 43 vs 4 minutes, a 10× ask and a different architecture.
  5. Optimising compute when egress dominates the bill (optimise the term that's actually big).
  6. Treating multi-region as "free reliability" instead of the most expensive, correlation- discounted nine.
  7. 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 with ValueError.
  • resource_scope_level — classify any ID as subscription / 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) × 43200 is 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

ConceptWhat to understand
ARM is the one control planeevery write is an idempotent PUT to a resource ID; the ID is the address, the RBAC scope, the Policy scope
Scope inheritancea role/policy at MG → Sub → RG → resource flows down; the scope of an ID decides who already has access
Control plane ≠ data planeOwner (manage the storage account) ≠ Blob Data Reader (read a blob) — different Actions vs DataActions
Serial multipliesmore hops in the request path → strictly lower composite SLA; adding a microservice is never free
Redundancy combines1 − ∏(1 − Aᵢ) — zones/regions raise the SLA, and the jump from three to four nines is an architecture, not a flag
Downtime makes it real99.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

FilePurpose
lab.pyskeleton with # TODO markers — your implementation
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_id rejects 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_type is virtualNetworks/subnets while its name is the leaf (snet-app), not the parent vnet.
  • You can explain why test_serial_sla_exact asserts 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_winner is 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 miniatureThe production mechanismWhere to verify it
parse_resource_idThe 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_levelThe 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_slaAzure 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_monthThe "nines → minutes" table every reliability review uses; 43,200 = 30-day-month minutes.WAF Reliability pillar
monthly_cost_usdThe shape of a real bill: compute (vCPU-hours), storage (GB-month, e.g. $0.018 LRS hot), and egress ($0.087/GB) — the term that ambushes multi-region designs.Azure Pricing Calculator
resolve_tradeoffA 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_tradeoff to 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/subnets resource and confirm az resource show --ids returns 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 in WARMUP.md Ch. 7.

Interview / resume

  • Talking points: "Walk me through what happens between az ... create and the resource existing." "Why is Owner not 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 the dependsOn they 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-if diff 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 (RBAC Actions − NotActions, then deny assignments win), validates it (Azure Policy can deny/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/202 with a provisioningState (Accepted → Running → Succeeded|Failed) and an Azure-AsyncOperation/Location URL the client polls until terminal. "The deploy is done" means the poll reached Succeeded, 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 dependsOn and the implicit dependencies ARM infers when one resource's property reference()s or resourceId()s another. ARM topologically orders the graph, deploys independent resources in parallel, and detects cycles.
  • IdempotencyPUT of 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 modesIncremental (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/Ignore without 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-written dependsOn), 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)

FieldValue
GoalBuild 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
ConceptsImplicit 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
Steps1. parse reference()/resourceId() out of (nested) property strings; 2. union with dependsOnextract_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 Testpytest 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 bulletBuilt 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:

  1. 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)
  2. Idempotent CI deploy — a pipeline that runs what-if on PR (the diff is the review) and deploy on merge, safely re-applying on every commit because the PUT is idempotent. (→ P08)
  3. Drift control — a deployment stack with denySettings that 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)
  4. 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)
  5. Cross-RP routing — a single template touching Microsoft.Storage, Microsoft.Network, and Microsoft.Compute, each routed to a different resource provider, each with its own api-version and async behavior. (→ all later phases)

Guides in This Phase

Key Takeaways

  • Everything in Azure is a PUT through 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() and resourceId() 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 provisioningState poll is. Debugging stuck deploys lives here.

Deliverables Checklist

  • Lab 01 implemented; all 25 tests pass against solution.py and your lab.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 PUT is idempotent and trace a re-deploy to all-NoChange
  • You can describe the provisioningState async-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

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:

  1. Authenticate. Your token is a JWT issued by Microsoft Entra ID. ARM verifies the signature against Entra's published keys (JWKS, selected by the kid in the header), and checks iss (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.
  2. Authorize. ARM computes your effective permissions: the union of Actions across all your role assignments at this scope and every ancestor scope, minus NotActions. Then — deny assignments win — any explicit deny overrides an allow. No matching allow, or a matching deny → 403. You build this in Phase 04.
  3. Validate. Azure Policy evaluates the request against assigned rules. A deny effect blocks the write (RequestDisallowedByPolicy); modify/append mutate the request (e.g. add a required tag) before it proceeds; audit just records non-compliance. Policy runs at create/update time, here, on the control-plane path. You build this in Phase 04 too.
  4. 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 get MissingSubscriptionRegistration.

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 a type, 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 → ∅.

Stepready (sorted)popemitted so far
0storage1, vnet1storage1storage1
1vnet1vnet1storage1, vnet1
2subnet1subnet1subnet1
3nic1nic1nic1
4vm1vm1vm1

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:

SituationActionEffect
resource does not existCreateprovision it to desired state
exists, desired ≠ actualModifyreconcile it to desired state
exists, desired = actualNoChangenothing 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 full PUT of 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 typeMeaning
Createresource will be added
Modifyresource exists; listed properties will change
Deleteresource will be removed (only in Complete mode / stacks)
NoChangeidentical; nothing happens
Ignoreexists 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 provisioningState on the resource: Accepted → Creating/Running → Succeeded (or Failed/Canceled), and
  • an Azure-AsyncOperation (or Location) header — a URL the client GETs repeatedly, honoring the Retry-After interval, 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, not resourceId(...). Because sa is a symbol, Bicep infers the dependency graph from the symbol references — you almost never write dependsOn. (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-version and catches typos and invalid properties before deploy.
  • Modules, loops, conditionals. First-class module, for, and if instead of copy-paste and copy blocks.

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 detachResources to leave it, or deleteResources to 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: denyDelete or denyWriteAndDelete — 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):

  1. _implicit_refs_in_value + the regex (Ch. 4). Write the regex that captures NAME from reference('NAME') and resourceId('NAME'). Recurse into dicts and lists — test_extract_implicit_resourceid_and_nested proves nested properties count.
  2. extract_dependencies (Ch. 4). Union dependsOn with the implicit refs; raise on a missing name; drop the self-edge. Tests: explicit+implicit union, self-reference dropped, missing-name raises.
  3. 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 unknown dependsOn and unknown implicit references.
  4. topological_order (Ch. 5). Kahn's algorithm, tie-break by name for determinism, raise ValueError("cycle: ...") if not every node is emitted. Tests: order respects deps, deterministic tie-break, cycle + self-cycle raise.
  5. deploy (Ch. 6–7). Validate the mode; topo-order; classify Create/Modify/NoChange against a copy of state (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.
  6. what_if (Ch. 8). Same classification, returned as Change objects, 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:

  1. Draw the ARM request pipeline: authenticate (JWT) → authorize (RBAC, deny wins) → validate (Policy) → route (resource provider) — and name the HTTP error each gate throws.
  2. Given a template, list its implicit (reference/resourceId) and explicit (dependsOn) edges and write a valid deploy order.
  3. Run Kahn's algorithm on paper and explain how the leftover-nodes test detects a cycle.
  4. State why an ARM PUT is idempotent and trace a re-deploy to all NoChange.
  5. Explain Incremental vs Complete and the exact blast radius of each — and why what-if is the defense.
  6. Describe the provisioningState async-poll loop and why dependents wait for a dependency's Succeeded.
  7. 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

🛸 Hitchhiker's Guide — Phase 01: ARM & the Deployment Engine

Read this if: you've run az deployment or terraform apply a 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') or resourceId('x') → ARM adds an edge to x automatically. You never wrote dependsOn, 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

ModeUn-templated resources in the RGUse
Incremental (default)left alonealmost always
CompleteDELETED (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

ThingValue
Default deployment modeIncremental
Resource ID shape/subscriptions/{s}/resourceGroups/{rg}/providers/{ns}/{type}/{name}
Every write isone idempotent PUT + api-version
Implicit-dependency functionsreference(), resourceId()
provisioningState pathAccepted → Running → Succeeded | Failed | Canceled
Async signals202 Accepted + Azure-AsyncOperation header to poll
MG tree max depth6 levels under root (P05, but same control plane)
what-if change typesCreate / 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 provisioningState is non-terminal (quota, a Failed dependency 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-dependsOn serialized 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 a dependsOn.
  • 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

  1. Thinking array position in resources is the deploy order (it's the graph).
  2. Adding dependsOn everywhere instead of letting references imply edges.
  3. Not knowing Incremental is the default — or what Complete deletes.
  4. Saying "the deploy is done" when the PUT returned but provisioningState isn't terminal.
  5. Calling a dry run "what-if" while mutating state — a what-if never mutates.
  6. Treating idempotency as magic and getting bitten by a random resource name.
  7. 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 ForceNew and 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 PUT that 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, the Complete-mode delete foot-gun, and the what-if dry 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 explicit dependsOn and the implicit dependencies parsed out of reference('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's InvalidTemplate) 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; raises ValueError("cycle: ...") on a circular dependency.
  • deploy(resources, state, mode) — process in topo order; idempotent PUTCreate / Modify / NoChange; Complete mode also Deletes 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 as deploy, returned as a list of Change objects, mutating nothing.

Key concepts

ConceptWhat to understand
Implicit vs explicit depsreference()/resourceId() create edges you didn't type; ARM finds them, so must you
Topological orderdependencies deploy before dependents; independents run in parallel (we serialise by name for determinism)
Cycle detectiona dependsOn loop is un-deployable; Kahn's leftover-nodes test finds it
Idempotent PUTdesired state, not a command → re-running converges (all NoChange), which is why IaC is safe to re-apply
Incremental vs CompleteIncremental leaves extras alone; Complete deletes anything not in the template — the classic 2 a.m. mistake
what-if is read-onlythe diff you read in the PR; mutating state in a "dry run" is a bug

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py prints a worked deployment plan
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_name matters — 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_change is the whole reason IaC works: a PUT of desired state that already matches is a safe no-op.
  • You can explain, in one breath, why test_complete_mode_deletes_extras is the most dangerous line in the suite — and when you'd ever want Complete mode.
  • 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 labReal ARM
extract_dependenciesARM's template expression engine evaluating reference()/resourceId() and building implicit edges, plus your literal dependsOn
build_dependency_graphthe deployment's internal DAG; an unknown reference is the deploy-time InvalidTemplate error
topological_orderthe deployment scheduler — except ARM deploys all currently-ready resources in parallel (bounded fan-out), not one-at-a-time
deploy Create/Modify/NoChangethe 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 deleteaz deployment group create --mode Complete — deletes every resource in the RG not in the template
what_ifaz 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 provisioningState that walks Accepted → Running → Succeeded|Failed over N "ticks," and make deploy poll until terminal — now you've modeled the 202/Azure-AsyncOperation loop.
  • 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, Delete the ones dropped from the stack (managed deletion) and reject an out-of-band Modify to a denySettings: denyDelete resource (drift protection).
  • Wire it to a real RG: az deployment group what-if -g rg -f main.bicep and 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 ARM PUT idempotent 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 PUT convergence, and a non-mutating what-if diff) 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 plan refreshes 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 tfstate incident there is.
  • State must be shared and locked. Two engineers running apply against 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 ForceNew and 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 import and state mv/rm exist.
  • 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. apply executes 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 attributes ForceNew: true because 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_destroy lifecycle 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 explicit depends_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 plan refreshes — 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 tfstate in 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 synth to 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, a plan you review, but a fragile artifact you must store, lock, and protect.

Labs

Lab 01 — Terraform State Engine (flagship, implemented)

FieldValue
GoalBuild 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
ConceptsConfig 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. Team remote-state bootstrap — a storage account + container holding tfstate with 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 run apply safely and one that corrupts state weekly. (→ P05, P12)
  2. 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)
  3. Drift reconciliation — a portal edit (someone bumped a tier in an incident) creates drift; the next plan shows changes nobody wrote. Decide: re-apply (config wins, revert the edit), import/update config (reality wins), or ignore_changes (accept the edit). This is the daily reality of shared estates. (→ P04, P05)
  4. CDKTF for a landing zone — express a repeated pattern (N spokes, each a VNet + subnet + NSG) as a CDKTF loop in Python, synth to JSON, and watch the same engine plan it — the case where a real language beats hand-written HCL. (→ P05, P06)
  5. 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

Key Takeaways

  • Terraform is the diff between config and state. Three artifacts — config (desired), state (recorded reality), plan (the diff) — and apply executes the plan. Hold them apart and the tool stops surprising you.
  • ForceNew is 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.py and your lab.py
  • You can name Terraform's three artifacts and say exactly what apply does
  • Given a diff and a provider schema, you can call update vs replace correctly 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)

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.

  1. Ask reality every time (stateless). This is ARM/Bicep (Phase 01). Each deploy is a PUT of 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.
  2. 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, plan can 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:

ArtifactWhat it isLives inAnalogy
Configurationthe world you want.tf / .tf.json / synthesized CDKTFthe desired end state
Statethe world Terraform remembers it builtterraform.tfstate (remote blob)its memory / model of reality
Planthe diff: config minus state, per resourcecomputed on plan, applied on applythe 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:

SymbolActionCondition
+createin config, not in state
~update in placein both, differs, only mutable attributes changed
-/+replacein both, differs, at least one immutable (ForceNew) attribute changed
-deletein state, not in config
(none)no-opin 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 (a lifecycle block): 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 use name_prefix. Many a create_before_destroy has 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.id in a config makes that resource depend on azurerm_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). A depends_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:

ChoiceMeaningWhen
Re-applyconfig wins; revert the out-of-band changethe portal edit was unauthorized/wrong; config is truth
Update config (or import)reality wins; bring config up to matchthe change was intentional and should be permanent
ignore_changesaccept ongoing drift on that attributesomething 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:

DimensionARM / Bicep (P01)Terraform (P02)
Modelstateless: idempotent PUT of desired state; RP is the source of truthstateful: diff config against a state file
State filenone — nothing to lose, store, or lockterraform.tfstate — must be stored, locked, protected
Driftrecomputed every deploy (no stale memory to drift)state can go stale; plan refreshes to detect it
ScopeAzure onlymulti-cloud / multi-provider (one graph spans clouds + SaaS)
Review artifactwhat-if (dry-run diff)plan (dry-run diff) — both are PR-time safety nets
Orderdependency-graph topo order; reverse for Complete-mode deletesdependency-graph topo order; reverse for all deletes
ReplaceRP decides; what-if shows itprovider ForceNew; -/+ in the plan
Ecosystemnative to Azure, first-class with new RP features4,000+ providers, but Azure features can lag the RP
Failure modeComplete-mode mass deletion (P01); async pollingstate 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):

  1. Resource + diff_attributes (Ch. 2–3). The model (address = type.name, attributes dict) and the leaf comparison: union of keys → (old, new) for changed/added/removed, omit identical. Validate both sides are dicts.
  2. plan_resource (Ch. 3–4). The four-way decision. current is Nonecreate; empty diff → no-op; any changed attr in force_newreplace; else update. The ForceNew boundary is the heart of the lab — test it both ways (force_new empty vs not) on the same diff.
  3. plan (Ch. 3, 5). Classify every config resource (look up its type's force_new set), mark addresses in state-but-not-config as delete, then order: creates/ updates/replaces in topological order, deletes in reverse. Don't emit no-op actions but count them in the summary. Reject duplicate addresses.
  4. 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).
  5. 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:

  1. Name Terraform's three artifacts (config, state, plan) and say exactly what apply does — and why ARM has no state file.
  2. Run the five-way diff in your head: given a config attr, a state attr, and a ForceNew flag, call create/update/replace/delete/no-op correctly.
  3. Explain the ForceNew boundary — why one immutable attribute turns the whole resource into a -/+, and what a -/+ destroys.
  4. Explain create-before-destroy vs destroy-before-create and the name-collision catch.
  5. Explain why creates go in topological order and deletes in reverse, with the subnet/NIC example.
  6. Explain remote state, the AzureRM backend, and the blob-lease lock — and why force-unlock is dangerous.
  7. Define drift (real ≠ state), describe plan's refresh phase, and list the three ways to reconcile it.
  8. Explain that CDKTF synthesizes to Terraform JSON and runs the same engine.
  9. Contrast Terraform's state model with ARM/Bicep's stateless PUT as 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 (ForceNew and 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

ThingNumber / fact
Terraform artifacts3: config, state, plan
Plan actions5: create / update / replace / delete / no-op
ForceNew changealways a replace (-/+) — destroys the real resource
Replace default orderdestroy-then-create (downtime); create_before_destroy inverts it
Delete orderreverse topological (dependents first)
Azure state backendAzure Storage blob; lock = blob lease
State secretsstored in plaintext in state → encrypt + restrict access
CDKTF outputcdk.tf.json (Terraform JSON) → same plan/apply engine
Driftreal ≠ state; plan refreshes (provider GET) then diffs
Lost stateTerraform 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 (a ForceNew attr — a rename, a region, a SKU) and someone skimmed past it and typed yes. 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), or ignore_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

  1. Not knowing the three artifacts — talking about apply without distinguishing config, state, and plan.
  2. Treating ~ and -/+ as the same ("it'll just change it") — a replace destroys the resource.
  3. Saying "Terraform handles drift automatically" — it detects drift via refresh; you decide how to reconcile.
  4. Not having an answer to "where does state live and how is it locked."
  5. Forgetting deletes go in reverse dependency order.
  6. depends_on everywhere — serialises parallelisable resources and hides the real graph.
  7. Thinking CDKTF is a different engine — it synthesizes to Terraform JSON and runs the same plan/apply.
  8. Forgetting state holds secrets in plaintext — so it needs encryption + access control like the secret store it effectively is.
  9. force-unlock as 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 ForceNew replace 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 apply against 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 PUT re-derives reality from the resource provider, so there is no file to drift. Terraform is stateful: it remembers what it created in a terraform.tfstate file, 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, the ForceNew "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):

  • Resourceaddress ("azurerm_storage_account.main" = type.name, the state key), attributes (a flat dict), and the set of ForceNew attribute 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 (a ForceNew attr moved → destroy-then-create).
  • plan(config, state, force_new_by_type, deps) — the whole diff: classify every resource, mark orphaned state for delete, and order it — creates/updates/replaces in topological order, deletes in reverse topological order. Returns a Plan with .actions and a .summary count.
  • apply(plan, config, state) — execute the plan, return the new state without mutating the input (the property terraform apply depends 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 change terraform plan surfaces after its refresh.

Key concepts

ConceptWhat to understand
Three artifactsconfig = desired, state = recorded reality, plan = the diff; apply executes the plan
The plan algorithmper resource: compare config vs state → create / update / replace / delete / no-op
ForceNew → replacean immutable attribute changed ⇒ destroy-then-create (-/+), not in-place
Dependency DAGbuild from references; apply in topo order, destroy in reverse topo order
Driftreal ≠ state (out-of-band change); refresh surfaces it; state is a claim, not truth
Idempotencyre-planning a converged config is all no-op; apply never mutates the input

Files

FilePurpose
lab.pyskeleton with # TODO markers and signatures in place
solution.pycomplete reference; python solution.py runs a worked plan + drift example
test_lab.pythe proof — 28 tests; run them red, make them green
requirements.txtpytest 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_boundary is the crux of the lab: the same diff is an update with an empty force_new and a replace the instant one changed attribute is immutable — and a replace destroys real infrastructure.
  • You can explain why test_plan_deletes_orphaned_state_in_reverse_order reverses the order: you cannot delete a subnet a NIC still sits in, so dependents die first.
  • You can explain why apply must not mutate its input state (a partially-applied run must leave the on-disk state recoverable; tests assert it stays untouched).
  • You can read a terraform plan and instantly flag every -/+ (replace) line as a potential outage before you type yes.

How this maps to real Azure (and real Terraform)

This labReal Terraform / AzureRM
state dict (address → attributes)terraform.tfstate JSON, stored in an Azure Storage blob backend
detect_driftthe refresh phase of terraform plan (reads each resource via the AzureRM provider, compares to state)
plan_resourcereplacethe provider schema's ForceNew: true on an attribute (e.g. a storage account's location, account_kind)
deps mapedges Terraform infers from ${azurerm_x.y.id} references + explicit depends_on
reverse-order deletesTerraform walks the dependency graph backwards to destroy
apply returns a new stateterraform 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 apply gets Error 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; the create_before_destroy lifecycle 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 plan emit a replace as an explicit (address, "destroy") + (address, "create") pair, and support a per-resource create_before_destroy flag 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 is no-op instead of create — 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 real terraform 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 synth it to cdk.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 its aud, and tell a delegated call from an app-only call by scp-vs-roles.
  • Recite and defend the validation order — structure/alg → signature → issaudnbf/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, the alg:none bypass, the timing side-channel a non-constant-time compare leaks).
  • Explain PKCE from first principlescode_challenge = BASE64URL(SHA256(verifier)), why the one-way hash means an attacker who intercepts the auth code still 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 & discoveryhttps://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 the issuer, the jwks_uri, and every endpoint so SDKs configure themselves. JWKS is the issuer's published set of public signing keys; the token header's kid selects 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 aud is the API, and it carries scp/roles; treat it as opaque and never decode it as a client. The ID token (OIDC) authenticates the user to the client — its aud is the client, and it carries sub/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 raw code_verifier at /token; the server re-hashes and compares. An attacker who intercepts the code can'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 → issaudnbf/exp (with clock skew) → authorization claim. scp (space-delimited) is delegated authority (a user delegated it); roles is app authority (the app itself was granted it). Skip aud and 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)

FieldValue
GoalBuild 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 → issaudnbf/expscp/roles in order, failing closed and naming the failed check
Conceptsbase64url (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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. SPA → API → downstream API — a browser app uses auth-code+PKCE to call your API (access token, aud = your API, scp carries 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)
  2. 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)
  3. API gateway authorization — API Management (or your API) validates every inbound JWT — kid→JWKS→signature→issaudexpscp/roles — then routes; an invalid aud is a 401, a missing scope is a 403. This lab's validate_jwt is that policy. (→ P09)
  4. 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)
  5. 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

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.
  • aud tells access token from ID token; scp-vs-roles tells 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 aud always (or you're a confused deputy); pin the alg (or you accept alg: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.py and your lab.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) and scp vs roles (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

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 analogyEntra
The blueprinta class definitionthe app registration (one, in the home tenant)
The running thingan instancea service principal (one per tenant the app is used in)
What you grant rights tothe instancethe 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-time code comes back.
  • …/{tenant}/oauth2/v2.0/token — the back-channel: a server-to-server POST that 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:

GrantWho's callingCredentialReturnsUse it for
Authorization code + PKCEa user, interactivelythe user's login + a PKCE proofaccess + ID + refreshSPAs, native/mobile, any user-present web app
Client credentialsan app, as itself (no user)the app's secret or certaccess (roles), no ID tokendaemons, cron jobs, service-to-service
On-behalf-of (OBO)a middle-tier API, as the userthe incoming user access tokena downstream access tokenAPI calls a second API keeping the user's identity
Device codea user on a screen with no browsera user_code typed on another deviceaccess + ID + refreshTVs, 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.

TokenAnswersaud (audience)CarriesWho reads it
Access token"may this caller do X to the resource?"the API (api://contoso-api)scp or roles, oid, expthe resource API
ID token"who is this user, to the client?"the client (its client_id)sub, name, preferred_usernamethe client app
Refresh token"give me a new access token"(opaque to the client)nothing you parsesent 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 aud is the client, not the API, so a correct validator (Chapter 9) rejects it on the aud check. (This is a real attack: present the ID token to the API and hope it doesn't check aud.)

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:

  1. The client invents a high-entropy random code_verifier (43–128 chars).
  2. It computes code_challenge = BASE64URL(SHA256(code_verifier)) and sends only the challenge to /authorize (method S256).
  3. At /token, it presents the raw code_verifier.
  4. 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.
  • signatureSIGN(key, base64url(header) + "." + base64url(payload)).

Crucial mechanical detail. The signature is computed over the encoded segments — the exact ASCII bytes header_b64 + "." + payload_b64not 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:

ClaimMeaning
ississuer — the tenant authority that minted the token
audaudience — who the token is for (string or list)
subsubject — the principal (a stable user id within the app)
expexpiry — epoch seconds after which it's invalid
nbfnot-before — epoch seconds before which it's invalid
iatissued-at
scpdelegated scopes, space-delimited (a user delegated these)
rolesapp roles (the app was granted these; no user)
appid/azpthe client application that obtained the token
oid / tidthe 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 iss or exp from 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 whatever alg the header claims, an attacker sets alg: "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 but HS256; a real one pins RS256.
  • 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. Use hmac.compare_digest, which compares in time independent of where the mismatch is.
  • aud is not optional. See Chapter 10 — skipping it is the confused-deputy vulnerability.
  • nbf/exp with 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 (a 401), 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
blockdeny the sign-in outright
grant + require MFAissue only after a second factor
require compliant deviceissue only from a managed/compliant device
require app-protection policyissue 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:

  1. 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.
  2. pkce_challenge(verifier, method) (Ch. 7) — S256 = b64url(sha256(verifier)), plain returns the verifier, unknown method raises. The test recomputes the expected challenge with hashlib.sha256 (a known-answer vector — it equals the RFC 7636 Appendix B value E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM for the standard's verifier).
  3. 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.
  4. 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, wrong iss, wrong aud, expired, not-yet-valid, missing scope, missing role, and the leeway boundaries.
  5. AuthorizationServer.authorize (Ch. 4, 7) — validate client/redirect/scope, store a PKCE-bound one-time code, return it.
  6. 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.
  7. token_client_credentials (Ch. 4, 10) — constant-time secret check, then a token with roles and no scp, 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:

  1. Explain app registration vs service principal (blueprint vs instance-in-a-tenant) and say which one you grant RBAC to.
  2. Draw the authorization-code + PKCE flow and state precisely what PKCE defends against and why the hash makes it work.
  3. Pick the correct grant for a SPA, a daemon, a middle tier, and a CLI — and say why each alternative is wrong.
  4. Tell an access token from an ID token by its aud, and a delegated call from an app-only call by scp-vs-roles.
  5. Recite the JWT validation order and justify each position — why signature precedes every claim, why aud is mandatory, why alg is pinned, why the compare is constant-time, why there's clock-skew leeway.
  6. Define the confused deputy and name the single check that prevents it.
  7. Frame Conditional Access as signals → decision and 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

🛸 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, then iss, then aud, 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

ClaimMeaningTrap
ississuer (the tenant)must equal your expected issuer exactly
audwho it's for (string OR list)skip the check → confused deputy
exp / nbfvalid window (epoch s)check with ±300 s skew, strict at the edge
scpdelegated scopes, space-delimiteda user is present
rolesapp roles, arrayno user — app acting as itself
appid/azpthe client appwho got the token
oid / tidobject id / tenant idthe principal and its tenant
kid (header)which JWKS key signed itpick the public key by this
alg (header)the signature algorithmpin 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

ThingValue
Clock-skew leeway±300 s (5 min) — Entra's own allowance
PKCE challengeBASE64URL(SHA256(verifier)), method S256
PKCE verifier length43–128 chars, high entropy
Entra signing algRS256 (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 issuerhttps://login.microsoftonline.com/{tid}/v2.0
Access-token lifetime~60–90 min (then use the refresh token)
scp vs rolesdelegated (user) vs app (daemon)
Implicit flowdead — 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 the aud check; 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 kid and refresh on an unknown kid.
  • "Someone forged an admin token."alg:none accepted (no algorithm pin) or a non-constant-time signature compare leaked the bytes. Pin the alg; use hmac.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:none attack — stripping the signature by claiming "no algorithm"; pin the alg.
  • PKCEchallenge = BASE64URL(SHA256(verifier)); secret-less proof for public clients.
  • scp vs roles — 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's kid selects 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

  1. Saying "OAuth" when you mean OIDC (or thinking OIDC replaces OAuth — it's built on it).
  2. Picking client-credentials where a user is present (you lost the user's identity).
  3. Using the implicit flow (it's dead) instead of auth-code + PKCE for a SPA.
  4. Decoding/trusting the access token in the front-end (it's opaque to the client).
  5. Validating the signature but not aud (the confused deputy) — or not pinning alg.
  6. Comparing the signature with == (timing side channel) instead of hmac.compare_digest.
  7. Zero clock-skew tolerance, or caching one JWKS key forever (self-inflicted outage).
  8. Confusing app registration with service principal — and not knowing which gets the RBAC.
  9. 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 + aud is 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 base64url codec 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 → issaud → 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 of if statements — 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 PKCE code_challenge are 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 the plain downgrade. 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 over base64url(header).base64url(payload) (deterministic), and split/decode one without trusting it (to read the kid before you verify).
  • validate_jwt(...) — the heart of the lab: verify the signature with a constant-time HMAC compare, then iss, then aud (string or list), then nbf/exp with leeway, then required_scopesscp and/or required_rolesroles — raising a ValueError that names which check failed.
  • AuthorizationServer — Entra's /authorize and /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 carrying roles (no user, no ID token) after a constant-time secret check.

Key concepts

ConceptWhat to understand
base64url, no paddingthe JOSE alphabet (-/_, no =); every token field uses it
PKCE S256challenge = BASE64URL(SHA256(verifier)); defeats auth-code interception for public clients
access vs ID tokenaccess token = aud is the API, carries scp/roles (authorization); ID token = aud is the client, proves who the user is (authentication)
scp vs rolesscp = space-delimited delegated scopes (a user is present); roles = app roles (daemon, no user)
validation orderstructure/algsignatureissaudnbf/exp → scope/role; you never read a claim you haven't verified
confused deputyskip aud and a token minted for service B is accepted by your service A — the canonical token-replay bug
alg: nonereject 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 codethe auth code is popped on redemption (success or failure) so an intercepted code can't be replayed

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py runs a full PKCE flow, a validation, and a client-credentials flow
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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 aud must be checked at all (the confused deputy).
  • You can explain why test_pkce_s256_known_answer_vector recomputes the challenge with hashlib.sha256 in the test — and why the solution's example prints E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-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_attack exists and what it would mean for a validator to accept alg: none.
  • You can explain why test_authcode_is_single_use matters: PKCE stops an attacker who intercepts the code, and single-use stops one who replays it.
  • You can state, without looking, the difference between scp and roles and which grant produces each.

How this maps to real Azure (Microsoft Entra ID)

The labReal Entra ID
AuthorizationServerthe tenant's v2.0 endpoints: …/{tenant}/oauth2/v2.0/authorize and …/oauth2/v2.0/token
RegisteredClientan 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 codethe front-channel redirect that authenticates the user (where Conditional Access runs) and returns a one-time code
token_authorization_codethe back-channel POST /token with grant_type=authorization_code + code_verifier; returns access + ID (+ refresh) tokens
token_client_credentialsgrant_type=client_credentials with a secret/cert; returns an app-only token with roles
HS256 signingEntra signs RS256 (asymmetric); you verify with the tenant's public key from JWKS, picked by the token's kid
validate_jwtwhat every protected API (and API Management) does on each request, against the keys at the tenant's /.well-known/openid-configurationjwks_uri
expected_iss / expected_audthe 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":...}]}); have validate_jwt select the key by the token's kid and verify with the public key. Now you've modeled real Entra exactly.
  • Discovery: serve a /.well-known/openid-configuration JSON with issuer, 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_authorization grant (return a user_code + verification_uri, poll /token until 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 with az account get-access-token, decode it on jwt.ms, and check your validate_jwt accepts it (with RS256/JWKS) and rejects it with the wrong aud.

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 scp and roles, 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 the aud?"
  • 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 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:

  1. Azure RBACauthorization. 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.
  2. Azure Policygovernance. Even if RBAC says yes, Policy can deny the write (or append/modify/audit it) based on the shape of the resource — its SKU, region, encryption setting, tags. This is the gate behind RequestDisallowedByPolicy.

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 (Owner reading a blob), (b) a NotActions subtraction (Contributor granting 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/modify at the request, audit/auditIfNotExists/deployIfNotExists on the compliance scan — and why deployIfNotExists needs 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 hierarchy management group → subscription → resource group → resource. You grant by adding an assignment and revoke by removing one (or by a deny assignment).
  • Role-definition internalsActions (allowed control-plane operations), NotActions (subtracted from this role's own Actions), DataActions / NotDataActions (the same pair for the data plane), and AssignableScopes (where the role may be assigned). Effective perms = union(Actions) − union(NotActions). Wildcards: * matches any run of characters including / (so Microsoft.Storage/*/read spans segments).
  • Control plane vs data planeActions manage the resource (create/configure a storage account); DataActions govern the data inside it (read a blob). They are separate sets, which is why Owner (Actions=["*"]) cannot read a blob — it has no DataActions; you add Storage 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/rg contains …/rg/providers/… but not …/rg2.
  • Azure Policy — a policyRule of if (a condition tree) and then.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 — and deny dominates. deny/append/modify run at create/update; audit* and deployIfNotExists run 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)

FieldValue
GoalBuild 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. Least-privilege CI/CD identity — a deployment service principal that is Contributor on one RG (not subscription Owner), 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)
  2. Break-glass and deny protection — a Deployment-Stack denySettings deny assignment that blocks delete on production resources even for Owner, with a documented break-glass account that is not in the deny scope. (→ P05)
  3. 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)
  4. Data-plane vs control-plane separation of duties — platform admins get Owner to manage a Key Vault but not read its secrets; app identities get Key Vault Secrets User (a data role) but cannot change the vault's firewall. (→ P12)
  5. 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

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). OwnerStorage 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 deliberatelydeny/append/modify shape the request; audit*/deployIfNotExists report and remediate on the scan; deny dominates them all.

Deliverables Checklist

  • Lab 01 implemented; all 43 tests pass against solution.py and your lab.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 Owner can'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 operation Contributor lacks
  • 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, NotActions subtraction, 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'm Owner?" from first principles, and you will have built both evaluators.

Table of Contents


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:

PatternMatchesDoesn't match
*everything
*/readany …/read…/write
Microsoft.Storage/*/readMicrosoft.Storage/storageAccounts/read, …/blobServices/readMicrosoft.Compute/…/read
Microsoft.Storage/storageAccounts/*every storage-account opMicrosoft.Compute/…
exact stringonly itselfanything 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:

RoleActionsNote
Owner["*"]full control plane + can manage access
Contributor["*"] minus Microsoft.Authorization/*/Write, …/Delete, …/elevateAccessmanage 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 own Actions.
  • DataActionsdata-plane operations the role allows.
  • NotDataActions — operations subtracted from DataActions.

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:

  1. Deny always wins. A deny in scope ends the evaluation False, regardless of any grant — even subscription Owner.
  2. Default deny. No matching allow → False. Least privilege is the floor, not an opt-in.
  3. Scope still applies to deny. A deny at rg does not block an action on a resource in rg2 — 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 locksCanNotDelete/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:

ConstructMeaning
{"field": F, "equals": V}field F equals V (case-insensitive strings)
{"field": F, "notEquals": V}field FV
{"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/supportsHttpsTrafficOnlyproperties.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:

EffectWhen it runsWhat it does
disabledturns the policy off (no-op)
auditscanlogs non-compliance; does not block
auditIfNotExistsscannon-compliant if a related resource is missing
appendrequestadds fields to the request (e.g. a default tag)
modifyrequestchanges/adds/removes properties or tags (needs a managed identity)
deployIfNotExistsscandeploys a remediating resource if one is missing (managed identity)
denyrequestblocks 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:

  1. Narrow the scope before you narrow the role. Contributor on one RG beats Owner on the subscription for a CI identity. Same role, far smaller blast radius. Scope is the cheaper, safer lever.
  2. Prefer the narrowest built-in role. Need to read blobs? Storage Blob Data Reader, not Owner. Microsoft maintains hundreds of granular built-ins for exactly this.
  3. Custom role only as a last resort. When no built-in fits, author a custom role with the exact Actions/DataActions and a tight AssignableScopes. It's more to maintain, so justify it.
  4. 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.
  5. Use deny assignments for guardrails that must survive privilege. Protect platform-managed resources from even Owner via stack denySettings.

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:

  1. action_matches(pattern, action) (Ch. 2) — translate *.*, re.escape every other char, anchor, case-insensitive fullmatch. Test the boundaries first: */read, mid-pattern *, exact, the literal-dot trap, case-insensitivity.
  2. RoleDefinition + role_permits (Ch. 3) — union(allow) − union(deny) per plane; coerce list args to tuples in __post_init__. The two key tests: NotActions subtraction, and Owner (Actions=["*"], no DataActions) failing a data action.
  3. scope_contains (Ch. 4) — normalize (lower, strip trailing /), then equality or scope + "/" prefix; root / contains all. Nail the rg-vs-rg2 test.
  4. RoleAssignment / DenyAssignment / can_perform (Ch. 5) — deny loop first (short-circuit False), then the allow loop, then default False. Raise on an unknown role name. Test deny-wins, out-of-scope deny, wrong-principal deny, default-deny.
  5. evaluate_policy (Ch. 6) — recursive _eval_condition (handle allOf/anyOf/ not then the leaf operators), a dotted _resolve_field with a _MISSING sentinel, and the then.effect return. Validate the shape and effect. Then compliance_summary tallies 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:

  1. Draw the two authorization gates (RBAC then Policy) and name the error code each emits.
  2. State the RBAC formula union(Actions) − union(NotActions) and explain why NotActions is subtraction, not a deny.
  3. Explain the control-plane / data-plane split and why Owner can't read a blob (and what role fixes it).
  4. Recite the RBAC evaluation order — deny first, then allow, default deny — and why deny is checked first.
  5. Explain the rg-vs-rg2 containment trap and how segment-aware matching avoids it.
  6. Name the four core roles and the operation Contributor lacks.
  7. List the seven Policy effects, order them by precedence, and say which run at request time vs the compliance scan.
  8. Hand-evaluate a nested allOf/anyOf/not policy against a resource JSON, including an exists on a present-but-false field.

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

🛸 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

RoleActionsThe gotcha
Owner*no DataActions → can't read a blob; can grant access
Contributor* minus Microsoft.Authorization/*manage everything except grant access
Reader*/readsees 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

ThingValue
RBAC evaluation orderdeny first → allow → default deny
Effective permsunion(Actions) − union(NotActions) (per plane)
NotActionssubtracts from this role only (not a deny)
Wildcard *matches any chars including / (spans segments)
Scope hierarchyMG → Sub → RG → resource; inherited down
Role assignments per subscription4,000 (hard limit — a real ceiling at scale)
Custom roles per tenant5,000
Management-group tree depth6 levels (excl. root + subscription)
Policy effect precedencedisabled < audit* < append/modify < deployIfNotExists < deny
Request-time effectsdeny, append, modify
Scan-time effectsaudit, 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 Owner but I can't read the blob!" → control plane vs data plane. Owner has no DataActions. Add Storage Blob Data Reader. The #1 RBAC ticket.
  • "My service principal is Contributor but the deploy says RequestDisallowedByPolicy." → RBAC passed; Policy denied the resource shape (region/tag/SKU/HTTPS). Read the policy's if to 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 Owner deleted 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 — Owner can remove a lock; a deny assignment overrides even Owner.
  • "Contributor can't grant my teammate access." → working as designed: Contributor has NotActions: Microsoft.Authorization/*/Write. Grant User Access Administrator (or Owner).
  • "My audit policy isn't blocking anything."audit never blocks; it reports on the scan. If you need to stop the create, the effect is deny.

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; deny dominates.
  • 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

  1. Granting Owner/Contributor at the subscription when an RG-scoped role would do (blast radius), or inventing a custom role when a built-in already fits.
  2. Thinking Owner implies data access (the blob trap) — or thinking NotActions is a deny.
  3. Forgetting deny is evaluated first and always wins — describing RBAC as "just add up the allows."
  4. Doing scope containment with a raw startswith and getting the rg-vs-rg2 bug.
  5. Confusing the two gates: blaming RBAC for a RequestDisallowedByPolicy (it's Policy).
  6. Using audit where the requirement is "must never be created" (that's deny), or reaching for a resource lock where a deny assignment is required.
  7. 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 User is a DataAction role).
  • 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/NotActions matcher, 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/*/read matches Microsoft.Storage/storageAccounts/read; */read matches any read; * matches everything. The literal . in Microsoft.Storage must not behave like a regex ..
  • RoleDefinition + role_permits(role, action, is_data=False) — effective perms = union(Actions) − union(NotActions), with a separate DataActions/NotDataActions pair for the data plane. This is the mechanism behind "an Owner cannot read a blob."
  • scope_contains(assignment_scope, resource_id) — segment-aware ARM-id containment that knows …/resourceGroups/rg contains …/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's if condition tree (field/equals/notEquals/in/exists/like under allOf/anyOf/not) with dotted-alias field lookups, and return the then.effect on a match (else "compliant"); roll many resources up into an effect-count.

Key concepts

ConceptWhat 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 planeActions manage the resource; DataActions govern data inside it — different sets
Owner ≠ Blob Data ReaderOwner has Actions=["*"] but no DataActions → can't read a blob
Additive + inheritedassignments add up and flow down MG → Sub → RG → resource
Deny always winsa deny assignment overrides every role assignment; evaluated first
Default denyno matching allow → False; least privilege is the floor
* is multi-segmentMicrosoft.Storage/*/read and */read both rely on * crossing /
The rg vs rg2 trapscope containment is by segments, not raw startswith
Policy effectsdeny blocks the write; audit/append/modify/*IfNotExists do other things
Condition treeallOf/anyOf/not + leaf operators over aliases (dotted resource paths)

Files

FilePurpose
lab.pyskeleton with # TODO markers — your implementation
solution.pycomplete reference; python solution.py runs three worked demos
test_lab.pythe proof — run it red, make it green (43 tests)
requirements.txtpytest 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 but action_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_actions proves 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_role returns False even though the principal is Owner at the subscription — the order of evaluation.
  • You can explain why exists: True must be True for a present-but-False field, 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 miniatureThe production mechanismWhere to verify it
action_matchesAzure'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_permitsA built-in or custom role definition: Actions, NotActions, DataActions, NotDataActions, AssignableScopes. Owner=["*"], Reader=["*/read"], Contributor=* minus Microsoft.Authorization/*.az role definition list --name Owner
RoleAssignmentThe (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_containsAn 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-winsDeny 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 planeOwner 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_policyAzure 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_summaryThe 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 RoleAssignment with an optional condition expression (@Resource[...] predicate) and short-circuit can_perform when it fails — the modern way to scope Storage 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 so evaluate_policy accepts the real alias Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly and resolves it to properties.supportsHttpsTrafficOnly. Then add an append/modify effect that returns the mutated resource, not just the effect string.
  • Add an initiative (policySet): evaluate a list of policies, and define effect precedence so a deny in any member dominates an audit in another.
  • Wire it to real Azure: create a custom role with az role definition create, assign it, and confirm a denied action fails with AuthorizationFailed; then write the matching deny-effect policy and watch the deployment get blocked at create time.

Interview / resume

  • Talking points: "Why can an Owner not read a blob, and what do you grant instead?" / "A service principal is Contributor at the subscription but a deploy fails with RequestDisallowedByPolicy — 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/NotActions wildcard matching, control-vs-data-plane permission sets, scope-hierarchy inheritance, and deny-assignment override) and an Azure Policy evaluator (nested allOf/anyOf/not condition 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 as deny (block) vs audit (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)

FieldValue
GoalBuild 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
ConceptsThe MG tree above subscriptions; downward inheritance; the effective-assignment formula; notScopes vs exemptions; subscription vending; initiatives and compliance scoring
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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.)
  2. 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.)
  3. 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.)
  4. The "we're failing audit" fire drill — given a compliance score of 71%, find the policy and the scope dragging it down, decide deny vs audit vs exemption for each gap, and write the remediation plan (DINE policies, time-boxed exemptions). (Builds on P04 effects, P13 monitoring.)
  5. Decommissioning safely — move a retiring subscription to the Decommissioned MG 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

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 toHow
P03 — Entra IDthe Tenant Root Group is the Entra tenant; the identities the tree authorizes come from here
P04 — RBAC & PolicyP04 evaluates one assignment; P05 decides which assignments are in scope by walking the tree — they are a matched pair
P06 — Networkingthe Connectivity MG holds the hub VNet, firewall, and DNS that Corp/Online inherit routing and resolution from
P08 — CI/CDsubscription vending and policy-as-code are pipelines — this is the governance content those pipelines deploy
P13 — Observabilitythe Management MG's central Log Analytics workspace is what compliance and audit read from
P15 — Capstonethe 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.py and your lab.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

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:

  1. Every subscription enforces the non-negotiables (allowed regions, required tags, no public IPs on internal workloads, mandatory diagnostic logging).
  2. A new subscription is governed the moment it exists — not after a manual checklist a human forgets to run.
  3. 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:

  • Readyprepare the environment. This is where landing zones live.
  • Governkeep 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:

PillarWhat it providesTrack phase
Management-group hierarchythe inheritance tree (this phase)P05
Policy-as-code baselinethe guardrails every subscription inheritsP04/P05
RBAC modelwho can do what, assigned at the right scopeP04
Hub networkingshared firewall, DNS, ExpressRoute/VPNP06
Centralized loggingone Log Analytics workspace for the estateP13
Subscription vendingthe automated "new subscription" pipelineP05

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):

LimitValueWhy it matters
Management groups per tenant10,000you will never hit it
Hierarchy depth6 levels of MGs under the rootthe cap your build_tree enforces
Levels including root + subscriptionup to 8 totalroot (1) + 6 MGs + the subscription (1)
MGs a subscription can belong toexactly 1vending is placement, not membership
Direct children of a nodeno fixed small capbreadth 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).

notScopesExemption
Lives onthe assignment (an array of excluded scopes)a separate Microsoft.Authorization/policyExemptions resource at a scope
Excludesa scope from this one assignmentan 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"
Lifecyclestructural, long-livedusually 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):

StepMechanismPhase
Place under the right MGthis lab's place_subscriptionP05
Inherit policy/RBAC baselineeffective inheritance (Ch. 4)P04/P05
Set a budget + cost alertsCost Management budgetP14/FinOps
Peer to the hub VNetVNet peering to ConnectivityP06
Wire DNS / firewall routesPrivate DNS, UDRs to the firewallP06
Bootstrap RBAC for the teamrole assignment at the subscriptionP04

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:

  • denyblock the non-compliant write at the control plane. A hard guardrail: you literally cannot create a public IP on a Corp subscription.
  • auditallow 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 deny policy 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 notScopes carve-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 audit initiative to deny, or added a new policy — and the existing estate was always non-compliant; the score just started counting it. Read the by_policy breakdown 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):

  1. 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."
  2. ancestors(mg_id, parent_map) — walk to the root, leaf-first. ancestors("corp", …) is ["corp", "landing-zones", "root"]; ancestors("root", …) is ["root"] (Ch. 3).
  3. effective_assignments(scope, assignments, hierarchy, exemptions) — select assignments whose scope is on the ancestor chain, drop those excluded by not_scopes for this scope or by an exemption, and sort by (depth, name) for determinism (Ch. 4). Test: union over ancestors, off-chain assignments excluded, notScopes exclusion, exemption exclusion, and identical output across two runs.
  4. 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).
  5. compliance_score(resources, initiative, evaluator) — any-fail per resource, a by_policy count, and score_pct (Ch. 6). Test 100%, a partial case with the by_policy breakdown, 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:

  1. Draw the canonical ALZ management-group hierarchy and say what each node is for.
  2. State the management-group limits — depth ≤ 6 under root, exactly one MG per subscription.
  3. Write the effective-inheritance formula and explain why it is a union (additive), not an override.
  4. Distinguish a notScopes carve-out from an exemption, with an example of each.
  5. Explain subscription vending and why placement is the step that makes the baseline inherit.
  6. Read a compliance score with a by_policy breakdown and name the policy + scope to fix first — and explain "audit first, then deny."
  7. 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 the bicep-lz-vending / Terraform lz-vending modules
  • Microsoft — Enterprise-Scale / ALZ reference implementations: Azure/ALZ-Bicep and Azure/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

ThingNumber
Max MG depth under root6 levels (depth 7 is rejected)
Total scope levels (root + MGs + sub)up to 8
MGs a subscription belongs toexactly 1
MGs per tenant10,000 (you'll never hit it)
Compliance score100 × compliant / total (empty estate = 100%)
Non-compliant ifany policy returns deny or audit failure
Rollout orderaudit 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 notScopes carve-out or a time-boxed exemption — never delete the policy (that breaks everyone).
  • "Compliance dropped to 60% overnight." → someone flipped an audit initiative to deny or added a policy; the estate was always non-compliant, the score just started counting. Read by_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 notScopes or 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.
  • notScopes vs 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

  1. Trying to answer "what governs this resource?" from the resource — instead of walking up.
  2. Thinking a child can override/delete an inherited policy. It can't; only notScopes / exemptions remove, and they're explicit.
  3. Confusing a sibling MG with an ancestor — inheritance is down, never sideways.
  4. Conflating notScopes and exemptions (scope-vs-assignment, structural-vs-time-boxed).
  5. Shipping a deny straight to prod instead of audit-first.
  6. Treating vending as a ticket-and-checklist instead of automated placement → the snowflake subscription problem the whole phase prevents.
  7. 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 small builtin_evaluator too.
  • canonical_alz_tree() — the reference ALZ hierarchy you must be able to draw from memory.

Key concepts

ConceptWhat to understand
MG tree above subscriptionsmanagement groups form a tree above subscriptions; one Tenant Root Group at the top
Depth ≤ 6Azure caps the hierarchy at 6 levels of MGs under root; depth 7 must be rejected
One MG per subscriptiona subscription belongs to exactly one management group — vending is placement
Inheritance is downwarda policy/RBAC assignment at an MG applies to every descendant scope
Effective = ∪ − exclusionslocal ∪ inherited − notScopes − exemptions; the whiteboard formula
notScopes vs exemptionnotScopes excludes a scope from one assignment; an exemption suppresses an assignment (time-boxed)
Compliance = any-faila resource is non-compliant if any policy flags it; audit counts as non-compliant even though it does not block

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_seven matters — 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_assignments returns results most-local-first and why that ordering is deterministic (sort by ancestor depth, then name).
  • You can articulate the difference between a notScopes exclusion (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 constructReal Azure
parent_map / build_treethe 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.scopethe scope of a policy/role assignment (an MG id, /subscriptions/{id}, /resourceGroups/{rg}, or a resource id)
not_scopesthe notScopes array on a policy assignment (and excludedScopes on initiatives)
exemptionsMicrosoft.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_assignments and 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_score accept 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 effect in the allowed set, and reject a PR that introduces a deny without 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 VirtualNetworkVirtualNetwork + AzureLoadBalancer inbound, 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/0 UDR to a VirtualAppliance force-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).
  • Topologyhub-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)

FieldValue
GoalBuild 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
Conceptsreserved 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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/0 to the firewall; NSGs per subnet tier (web/app/data). Your effective_next_hop and evaluate_nsg are the verifier for "does this packet take the path I think?"
  2. 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_fqdn reproduces the make-or-break DNS step.
  3. 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.
  4. 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?
  5. 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 az one-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 /32 UDR pins one host; a /0 UDR 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.py and your lab.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

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:

AddressUse
.0 (network)the network identifier — never assignable
.1the default gateway (the VFP's router for this subnet)
.2, .3mapped 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.

PrefixTotalUsableTypical use
/2983smallest possible; tiny PE/utility subnet
/281611small subnet
/273227GatewaySubnet minimum (Microsoft recommends /27)
/266459app tier
/24256251the comfortable default
/1665,53665,531a 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 .0 and 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 matchfirst 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:

DirectionPriorityRuleEffect
Inbound65000AllowVnetInBoundallow VirtualNetworkVirtualNetwork
Inbound65001AllowAzureLoadBalancerInBoundallow AzureLoadBalancer → any
Inbound65500DenyAllInBounddeny everything else
Outbound65000AllowVnetOutBoundallow VirtualNetworkVirtualNetwork
Outbound65001AllowInternetOutBoundallow any → Internet
Outbound65500DenyAllOutBounddeny 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 — AllowVnetInBound lets 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:

  1. 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-all 0.0.0.0/0 → Internet.
  2. User-Defined Routes (UDRs) — your route table, attached to a subnet, overriding the system routes.
  3. 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 None next hop is an error." No — it is a deliberate blackhole, and a sneaky way to drop traffic with no log.

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:

  1. the Private Endpoint (the NIC + private IP),
  2. the Private DNS zone privatelink.blob.core.windows.net with an A record for the resource pointing at the private IP (auto-created if you let Azure manage it),
  3. 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.netacct.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:

ServiceLayerScopeDoesUse when
Azure Front DoorL7 (HTTP/S)globalglobal routing, anycast, caching/CDN, WAF, TLS offload, failover across regionsa public web app served worldwide; multi-region failover
Application GatewayL7 (HTTP/S)regionalpath/host routing, WAF, TLS, cookie affinity, rewriteregional web app needing WAF + URL routing
Load BalancerL4 (TCP/UDP)regionalflow-hash distribution, health probes, ultra-low latency, any TCP/UDPnon-HTTP, very high throughput, or internal (ILB)
Traffic ManagerDNSglobalDNS-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

SymptomFirst suspectThe mechanism
"Private Endpoint not working; resolves to public IP"Private DNS zone not linked to this VNetCh. 5 — no link → public record returned
"Packet dropped, every rule looks fine"a lower-priority Deny shadowing the AllowCh. 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 firewallCh. 4 — force-tunnel + blackhole
"Two VNets won't peer"overlapping CIDRsCh. 2 — overlapping address space can't peer
"A→C can't talk, A→B and B→C work"peering is non-transitiveCh. 6 — needs hub routing/firewall in path
"Intra-VNet traffic I meant to block still flows"AllowVnetInBound default is allowCh. 3 — must add a Deny to segment
"Return traffic blocked" (you wrote an inbound deny)NSGs are statefulCh. 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):

  1. CIDR mathsubnet_usable_hosts(p) is ( 2^{(32-p)} - 5 ); raise for ( p>29 ) (the /29 floor) and for out-of-range prefixes. cidr_contains and split_subnet are thin wrappers over ipaddress — let the stdlib parse, you add the Azure rules (Ch. 2).
  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), then evaluate_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.
  3. RoutesRoute.__post_init__ validation (next-hop types, VirtualAppliance needs an IP), then effective_next_hop: keep matching routes, raise if none, pick longest prefix with the UDR > BGP > system tie-break via a min key of (-prefixlen, origin_priority, name) (Ch. 4). The test that proves it: a /32 UDR beats /0, and a UDR /0 beats a system /0.
  4. Private DNSresolve_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:

  1. State why a /29 has 3 usable hosts and why /29 is Azure's floor (the 5 reserved IPs and ( 2^{(32-p)} - 5 )).
  2. 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.
  3. Recite the six default rules and explain why intra-VNet is allowed but inbound-from- internet is denied by default.
  4. Explain stateful evaluation and why you never write a return rule.
  5. Pick the next hop from an effective route table by longest-prefix match and apply the UDR > BGP > system tie-break.
  6. Draw the Private Endpoint DNS override (CNAME chain into the privatelink zone) and say exactly which missing object causes a public-IP resolution.
  7. 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 VirtualNetworkVirtualNetwork 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 privatelink zones 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/16 ranges) 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

ThingNumber
Reserved IPs per subnet5 (.0, .1, .2, .3, broadcast)
Usable hosts2^(32−prefix) − 5
Smallest usable subnet/29 → 3 usable hosts
GatewaySubnet/27 or larger (recommended)
NSG user priority range100–4096 (lower = first)
NSG default priorities65000 / 65001 / 65500
AzureLoadBalancer probe IP168.63.129.16 (also Azure DNS / wireserver)
Route selectionlongest prefix, tie → UDR > BGP > system
Private DNS zone (blob)privatelink.blob.core.windows.net
RFC 1918 ranges10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
VNet scopeone 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 VirtualNetworkVirtualNetwork, allow AzureLoadBalancer in, allow Internet out, 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. /32 beats /24 beats /0.
  • Equal prefix → UDR > BGP > system. That's why a UDR 0.0.0.0/0 overrides 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).
  • 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.netprivatelink.blob.core.windows.net zone → 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.*). nslookup on 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/0 UDR 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/16 with 10.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; /32 beats /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

  1. Saying "higher priority number wins" — it's lower.
  2. Writing an inbound rule for return traffic — NSGs are stateful.
  3. Thinking a Private Endpoint is private the moment it's created — it's the DNS link.
  4. Assuming the network spans regions or that any two VNets can peer — regional, and non-overlapping CIDRs only.
  5. Forgetting the 5 reserved IPs and the /29 floor in subnet math.
  6. Putting WAF/TLS termination on the L4 Load Balancer — that's the L7 layer (Front Door / App Gateway).
  7. Force-tunnelling 0.0.0.0/0 to 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 /32 UDR pins one host to a firewall and how 0.0.0.0/0 force-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 the privatelink zone, 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

ConceptWhat to understand
5 reserved IPsevery subnet loses network + .1/.2/.3 + broadcast → usable = 2^(32−p) − 5
/29 minimuma /29 has 8 − 5 = 3 usable; anything smaller is rejected by Azure
NSG is statefulyou evaluate the originating direction only; return traffic is implicit
Priority = lowest-firstpriority 100 beats priority 300; first match wins, so a low-number Deny shadows a high-number Allow
Default rulesallow VirtualNetworkVirtualNetwork, allow AzureLoadBalancer in, deny all else
Longest-prefix matchthe route with the most specific (longest) prefix decides — /32 beats /0
Route tie-breakat equal prefix length: UDR > BGP > system
Private Endpoint DNSsuccess = private IP via a linked privatelink zone; broken DNS = public IP = the classic bug

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_boundary asserts 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_wins is 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_0 chooses the /32 route even though a 0.0.0.0/0 route also matches — and why test_udr_beats_system_at_equal_prefix needs the origin tie-break.
  • You can explain why test_private_dns_returns_public_ip_when_not_linked is 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 labReal AzureNotes / limits
subnet_usable_hostssubnet sizing during VNet designAzure reserves 5 IPs/subnet; smallest is /29; the gateway subnet wants /27+
evaluate_nsgNSG security-rule evaluation in the host VFPreal priorities are 100..4096 for user rules, 65000+ for defaults; flows are stateful
effective_next_hopthe 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_fqdnPrivate Endpoint + Private DNS zone + VNet linkthe 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/dest be 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 expand Storage.WestEurope to its prefix list, so _endpoint_matches resolves real tags.
  • BGP path selection: give Route an 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 with az 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:

  • ":latest was 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 Pending and there's plenty of CPU on the dashboard — why won't it schedule?" (the scheduler packs by requests, 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:… (not app: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 Pending pod from first principles. Map kubectl describe pod output to the scheduler's filter predicates — Insufficient cpu/memory is requests-vs-allocatable arithmetic; had untolerated taint is 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 distributionpull = resolve tag → 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 pathsACA (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)

FieldValue
GoalBuild 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)
ConceptsContent 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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 everything FROM it.
  2. A multi-region, fast cold-start cluster. ACR geo-replication puts blobs in every region an AKS cluster runs in, so a node in westeurope pulls 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.
  3. 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.
  4. A storage-reclamation program. Untagged manifests pile up from CI; an acr purge Task 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

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 :latest as 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 Pending pod is arithmetic, not magic. The scheduler places by requests under taints against allocatable capacity; Insufficient cpu and untolerated taint are 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.py and against your lab.py
  • You can draw the image model (layer → digest → manifest → tag) from memory
  • You can explain why a warm-cache pull downloads only the new layers
  • You can explain why GC after untagging reclaims exclusive blobs but keeps shared ones
  • You can debug a Pending pod from kubectl describe output 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 pod Pending. By the end you can answer "why did :latest change 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

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:

  1. an ordered stack of read-only layers, each layer a tarball of filesystem changes (files added/modified/deleted) contributed by one Dockerfile instruction;
  2. a config — a small JSON document with the entrypoint, env, working dir, exposed ports, and the ordered list of layer digests (the rootfs.diff_ids and history).
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:12 share 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 ./app busts only layer2 and reuses layer0/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:latest is a suggestion. In the lab, resolve() accepts either, and tag() 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):

  1. For each layer + the config, the client computes the digest and asks the registry "do you already have sha256:…?" (a HEAD to /v2/myrepo/app/blobs/<digest>).
  2. It uploads only the blobs the registry is missing. (Shared base already there → skipped. This is server-side dedup.)
  3. 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:

SKUStorageThroughputGeo-replicationNotable
Basiclowlownodev/test
Standardmoremorenomost prod
Premiummosthighestyesgeo-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:12 rebuilds everything FROM it), 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:

  1. 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).
  2. 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:

requestlimit
Used bythe 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 itOOM-killed
If unsetscheduler assumes 0 → overcommit riskcontainer 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:

AKSAzure Container Apps (ACA)Azure Container Instances (ACI)
Modelfull Kubernetesserverless containers (Kubernetes hidden)a single container group
ScalingHPA + cluster autoscalerscale-to-zero, KEDA event-drivennone (one shot)
You operatenodes, upgrades, k8salmost nothingnothing
Best forplatforms, complex topologies, full k8s APImicroservices, event/queue workers, HTTP appsbatch jobs, CI agents, quick tasks
Billingper node-hourper-request / per-active-second, $0 idleper-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:

  1. digest(data) (Ch. 2) — "sha256:" + hexdigest. Verify against the known sha256("") constant; confirm one flipped bit changes it.
  2. Registry.put_blob / Manifest.blobs (Ch. 2–3) — store-by-digest with dedup (identical bytes → one blob). The blob_count() is your dedup proof.
  3. put_manifest / _manifest_digest (Ch. 3) — content-address the manifest; re-pushing the same image must return the same digest and store nothing new.
  4. tag / untag / resolve (Ch. 3) — the mutable pointer; resolve accepts a tag or a digest; re-tagging moves the pointer (the :latest foot-gun).
  5. 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.
  6. 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.
  7. replicate (Ch. 7) — copy the manifest + only the destination's missing blobs; the second shared-base image copies almost nothing.
  8. Node / Pod / schedule (Ch. 8–9) — first-fit bin-pack by requests under taints, decrementing capacity; a pod that fits nowhere is None (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:

  1. Draw the image model — layer → digest → manifest → tag — and explain what each is.
  2. Explain content addressing and the three properties it buys (dedup, tamper-evidence, cache validity).
  3. State why production pins a digest, not :latest, and what "the tag changed under me" actually means.
  4. Trace a pull as a set difference and say why a warm-cache pull downloads only new layers.
  5. Run garbage collection in your head: why deleting tags ≠ reclaiming storage, and why a shared layer survives until the last referencing tag is gone.
  6. Explain geo-replication as content-addressed copy-only-missing, and why replicating a shared-base image is nearly free.
  7. Describe the scheduler's filter step (PodFitsResources + TaintToleration) and explain a Pending pod from kubectl describe output.
  8. Distinguish requests (scheduler/placement) from limits (kubelet/enforcement) and name the three QoS classes and their eviction order.
  9. 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 == limitsGuaranteed (evicted last under memory pressure); requests < limitsBurstable; 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 Dockerfile and kubectl apply but "why did :latest change?", "why did deleting tags free no space?", and "why is my pod Pending?" 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

ThingValue / rule
Digestsha256: + 64 hex chars (32 bytes)
Tagmutable pointer → digest; pin prod by @sha256:…
Dedupidentical bytes → one blob, registry-wide
Pull downloadmanifest.blobs() − client_cache (only missing)
GC rootsthe tags (mark-and-sweep); untag ≠ reclaim until GC runs
ACR geo-replicationPremium SKU only
Auth (prod)managed identity + AcrPull, never admin password
Scheduler places byrequests (cpu millicores, mem MiB), NOT usage
Limit enforced bythe kubelet: CPU throttled, memory OOM-killed
1000m CPU= 1 vCPU; requests are in millicores
QoS order (evicted first→last)BestEffort → Burstable → Guaranteed
Pending causesInsufficient cpu/memory or untolerated taint
MG/scope, RBACAcrPull/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

  • ":latest broke 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 Pending but the dashboard shows the node 20% busy." → the scheduler packs by requests, not usage; the node's CPU is already reserved. describe pod says Insufficient cpu. Right-size requests; don't stare at kubectl 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 a nodeSelector), 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

  • Digestsha256:…; 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.
  • Allocatablecapacity minus 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

  1. Saying "an image is a binary/VM" — it's content-addressed layers + a config + a manifest.
  2. Treating :latest as a deployment contract instead of a mutable pointer. Pin the digest.
  3. Thinking deleting tags reclaims storage. Untag and GC; measure the exclusive closure.
  4. Believing pull cost ∝ image size. It's ∝ the layers you don't have cached.
  5. Reading kubectl top to explain a Pending pod. The scheduler packs by requests.
  6. Confusing requests and limits — or setting limits without requests (BestEffort surprise).
  7. Removing a taint to schedule a pod, instead of adding the toleration the taint demands.
  8. Pulling with the ACR admin password in prod instead of a managed identity + AcrPull.
  9. 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 + AcrPull is 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 sha256 digest, 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 is Pending.

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 is Pending (None).

Key concepts

ConceptWhat to understand
Content addressinga blob's name is the hash of its bytes → dedup and tamper-evidence are free
Digest vs tagdigests are immutable; tags are mutable pointers → pin prod by digest
Manifest closurethe set of blobs (config + layers) you must possess to run an image
Pull = set differencedownload = manifest's blobs minus what the client already cached
GC reachabilitya blob is garbage iff no tagged manifest reaches it; shared layers survive
Replication = missing-onlycopy the small manifest always, the big blobs only if absent
Schedule by requestsplacement uses requests (the floor), not limits (the ceiling)
Pending is arithmeticunschedulable = requests exceed remaining capacity, or no taint tolerated

Files

FilePurpose
lab.pyskeleton with # TODO markers and signatures/docstrings in place
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_once asserts 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_gone only 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_name produces the same result regardless of input order, and why worker/zeta lands as Pending.
  • Given a docker pull that "downloads almost nothing," you can say why (cached base layers), and given a Pending pod you can name the two possible causes (capacity, taints).

How this maps to real Azure

Lab constructReal Azure / OCI mechanism
digest()the OCI image-spec sha256: content descriptor digest
Registry blob/manifest storean ACR repository's data plane (the OCI distribution-spec /v2/ API)
tag is a mutable pointermyacr.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 Pendingkubectl describe pod0/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; make pull select by platform.
  • Add content trust: sign a manifest digest (HMAC over the digest with a key) and make pull refuse an untrusted/altered manifest — model Docker Content Trust / Notation.
  • Add soft-delete: untag moves a manifest to a tombstone with a retention window; garbage_collect only sweeps tombstones past their window; restore brings 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 Pending pod evicts a lower-priority pod to make room; show the evicted pod becomes Pending.

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 Pending pods 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:

  1. 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 of failed()). The execution model is the knowledge.

  2. 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.

  3. 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.

  4. The supply chain is now an attack surface with a name (SLSA). SolarWinds, Codecov, and the xz backdoor 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; always runs regardless; failed runs 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 its succeeded-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)

FieldValue
GoalBuild 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)
ConceptsPipelines 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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 subject per environment) and approval gate. No secret anywhere. Prove that a non-main branch cannot acquire the prod credential.
  2. 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.
  3. 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.
  4. Infra pipeline. The pipeline runs terraform plan (P02), posts the plan as a PR comment, requires an approval, then apply on merge — the IaC change goes through the exact same DAG + gate + keyless-auth machinery as app code.
  5. 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

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/aud match; 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.py and your lab.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 dependsOn graph, 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

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):

LevelAzure PipelinesGitHub ActionsWhat it is
topstage(job groups via needs)a deployment boundary, usually one per environment
middlejobjoba unit that runs on one agent/runner; the unit of parallelism
bottomstepstepone 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:

ConditionRuns whenReal use
succeeded (default)all dependencies succeededthe normal case — deploy only if tests passed
alwaysregardless of dependency resultsnotify / cleanup / publish-logs — must run even on failure
failedsome dependency failedrollback / 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 succeeded condition 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 meant failed(). always runs no matter what; failed runs 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 crucially sub (the subject, identifying which repo, branch, or environment this run is), plus exp (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, this subject, and this audience." 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:

PropertyStored SP secretOIDC federation
Credential at restyes, long-lived, always presentnone — nothing to steal
Validity windowmonths/yearsminutes (the token's exp)
Usable from anywhereyesno — only a run that the issuer vouches for
Scoped to a branch/envnoyes — via the subject
Rotationmanual, perpetualn/a — there's nothing to rotate
Leak in logs/forkscatastrophiclow 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/xno match, denied. A pull request from a forked repo produces sub = ...:pull_requestno 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.

StrategyRollback speedExtra costMixed versions live?
Blue-greeninstant (swap back)2× environmentno (atomic swap)
Canaryfast (drop the slice)small (the canary %)yes (canary + stable)
Rollingslow (re-roll batches)noneyes (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:

ArtifactWhat it isProducedVerified
Artifactthe built thing (image, zip, package) — content-addressed by digestbuild stageby digest, on every pull
SBOMsoftware bill of materials — every dependency + version (CycloneDX / SPDX)build stagescanned for known CVEs at the gate
Provenancea signed statement: this digest was built by builder X from source commit Y (in-toto / SLSA)build stageat the deploy gate
Signaturea cryptographic signature over the artifact + provenance (Sigstore/cosign)build/sign stageat 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:

  1. Stage + _topological_order — the Stage dataclass validates its name, 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 with test_cycle_raises, test_unknown_dependency_raises, test_topo_order_is_deterministic.
  2. 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) or outcomes[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.
  3. exchange_oidc_token + FederatedCredential + subject_matches — exact issuer and audience, subject via subject_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, and test_oidc_deny_by_default_no_creds.
  4. 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). Pin test_canary_rolls_back_at_failing_step (returns failed_at and final_pct: 0) and test_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:

  1. 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).
  2. State the execution rule in one sentence ("a stage runs iff every dependency satisfied its condition and any required approval was granted").
  3. Distinguish succeeded / always / failed conditions and name the real stage that uses each (deploy / notify / rollback).
  4. 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).
  5. 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.
  6. Explain why a wrong-branch subject is denied and why that denial is the security control.
  7. Contrast blue-green, canary, and rolling on rollback speed, extra cost, and mixed- version exposure — and say which rollback is instant and why.
  8. 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/login GitHub 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/aud semantics: 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 .yml turn 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:

  1. 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.
  2. 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 + aud exactly. The subject pins the branch/environment — the wrong subject is denied. That denial is the security.
  3. 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

ThingNumber / rule
Default stage conditionsucceeded — needs all deps green
The three conditionssucceeded / always / failed
OIDC token lifetimeminutes (exp), minted per run
Entra exchange audienceapi://AzureADTokenExchange
Match requirementiss AND sub AND aud, exact, deny by default
Federated creds per identity~20 max
GitHub OIDC permissionpermissions: id-token: write (+ contents: read)
Wrong-branch errorAADSTS700213 (no matching federated credential)
Blue-green rollbackinstant (swap back; old slot stays warm)
Canary on failureroll 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 succeeded condition wasn't met → it skipped. Skipped ≠ failed. Check the upstream results and the stage's condition.
  • "Rollback fired on a green build." → someone wrote condition: always() where they meant failed(). always runs no matter what; failed only 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/main or ...:environment:production; never federate pull_request for 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.
  • Conditionsucceeded/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

  1. Calling a pipeline "a script" — missing that it's a DAG with conditions and gates.
  2. Confusing always() and failed() (and being surprised when rollback runs on success).
  3. Thinking "skipped" means "failed" — they're different statuses with different causes.
  4. Storing a long-lived service-principal secret in CI instead of federating with OIDC.
  5. Setting the federated-credential subject too broad (any branch / PRs) — a fork PR then owns your cloud.
  6. Treating "deploy" as a copy, with no canary/health gate/rollback path defined up front.
  7. Saying "it's signed so it's safe" — signed ≠ attributable to my hardened builder (that's provenance + SLSA).
  8. 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 have depends_on edges, a condition (succeeded / always / failed), and an optional approval gate. A stage is skipped when a dependency didn't satisfy its condition, blocked when approval is required and not granted, else it runs to succeeded / 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 its iss + sub + aud. Issuer and audience match exactly; subject matches exactly (with one documented trailing-* pattern). Deny by default. A wrong-branch sub is 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

ConceptWhat to understand
The DAG is the pipelinestages → 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 propagatesa failed/skipped/blocked dependency is not succeeded, so succeeded-condition dependents skip
always vs failedalways runs regardless (notify/cleanup); failed runs only when a dep failed (rollback)
Determinismsorted topo order → same input, same {stage: status} every run; CI must be reproducible
OIDC federationpresent a short-lived OIDC token; Entra exchanges it by matching iss/sub/audno stored secret
The subject is the knobsub pins the branch/environment; the wrong sub is denied — that's why it beats a long-lived secret
Blue-greentwo slots, instant atomic swap, instant rollback (the idle slot stays warm)
Canaryramp %, gate on health, auto-rollback on the first failing step; limits blast radius

Files

FilePurpose
lab.pyskeleton with # TODO markers — implement these
solution.pycomplete reference; python solution.py runs a worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_dependency returns skipped (not failed) for deploy: a succeeded condition needs all deps green; a failed dep means the condition is unmet, so the stage never runs.
  • You can explain the difference between always and failed conditions, and which one a rollback stage uses vs a notify stage.
  • You can explain why test_oidc_reject_wrong_subject_branch is the whole point of OIDC federation: same repo, same issuer, same audience — but a different sub (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_rollout returns final_pct: 0 (not the last step) on rollback, and final_pct: 100 on success regardless of the last step value.

How this maps to real Azure

Lab pieceReal Azure / GitHub mechanismNotes & limits
Stage + depends_onAzure Pipelines stages: with dependsOn:; GitHub Actions jobs.<id>.needs:both build a DAG and run it in topological order
conditionAzure Pipelines condition: succeeded()/always()/failed(); GitHub if: success()/always()/failure()the default is "run iff all dependencies succeeded"
approvalAzure Pipelines Environments with approval checks; GitHub Environments with required reviewersa real gate also supports timeouts, business hours, and exclusive locks
run_pipeline skip/blockthe agent never even allocates a job for a skipped stage; a blocked stage shows "waiting" until approvedreal gates can expire and auto-reject after N days
FederatedCredentialan Entra app registration / user-assigned managed identity federated credential (issuer/subject/audience)max ~20 federated credentials per identity
exchange_oidc_tokenthe client_credentials grant with client_assertion_type=...jwt-bearer presenting the CI OIDC tokenEntra validates the OIDC token's signature via the issuer's JWKS before this match — the lab models the match, not the crypto
subject pinrepo: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_swapAzure App Service deployment slot swapthe swap is atomic at the load-balancer; the old version stays warm in the idle slot
canary_rolloutAKS/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, and verify_provenance that a deploy step calls before promoting — model the "only deploy artifacts our builder produced" gate.
  • Matrix fan-out: extend Stage to expand a matrix={"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 with azure/login@v2 using permissions: id-token: write — no secret in the repo. Then break it on purpose by deploying from a non-main branch and watch the token exchange fail with AADSTS700213.

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 semanticsGET (safe, cacheable), PUT and DELETE (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 pressure2xx success, 4xx you (the client) made a mistake, 5xx we (the server) did; and inside 4xx the 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 inbound policies that authenticate (validate-jwt), authorize (claim checks), throttle (rate-limit-by-key), and transform; the backend call; the outbound reshaping; and on-error — and explain why each policy lives in the section it does.
  • Separate authentication from authorization at the edgevalidate-jwt proves the token is real, current, and for this API (signature / iss / aud / exp); the scope check (scp delegated vs roles app-only) decides whether this caller may invoke this operation. AuthN is 401-or-pass; AuthZ is 403-or-pass. Conflating them is the most common edge bug.
  • Do the rate-limit arithmetic — a token bucket with a capacity (burst) and a refill_per_sec (sustained rate); on empty, a 429 with a Retry-After that 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 why GET /users/42 and DELETE /users/42 are 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 not POST). PATCH is 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 a POST (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. 2xx success (200 OK, 201 Created, 204 No Content); 3xx redirect; 4xx client error (400 malformed, 401 unauthenticated, 403 unauthorized, 404 not found, 409 conflict, 415/406 content negotiation, 422 semantic, 429 too many requests); 5xx server error (500, 502, 503, 504). The split matters for retries: a client may retry 5xx (our fault, transient) and 429 (with backoff) but must not retry 4xx like 400/401/403 unchanged — the request itself is wrong.
  • 401 vs 403 vs 429 — the three that decide client behavior. 401 Unauthorized is misnamed: it means un-authenticated — no token, or an invalid/expired one; the client should re-authenticate. 403 Forbidden means 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 Requests means authenticated and authorized but over budget — the client should back off for Retry-After seconds. 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 next link/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 is 406/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-jwt authenticates: the signature proves the token wasn't forged, iss proves Entra issued it, aud proves it's for this API (skip this and you're a confused deputy), exp/nbf prove it's current. The scope check authorizes: scp (space-delimited delegated scopes — a user is present) or roles (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 capacity tokens and refills at refill_per_sec; each request spends one; an empty bucket yields 429 + a Retry-After of (1 − tokens) / refill_per_sec. capacity is the burst you tolerate; refill_per_sec is 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)

FieldValue
GoalBuild 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
ConceptsPath-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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. SPA → APIM → API → downstream API. A browser app got an access token via auth-code + PKCE (Phase 03); APIM validate-jwt checks aud = your API and scp carries the user's scopes; an insufficient scope is a 403, an expired token a 401. This is Phase 03's validate_jwt running as a policy on every request. (→ P03)
  2. Daemon → APIM → API. A nightly job uses client-credentials; its token carries roles, not scp, so the operation authorizes on app roles. The grant choice (P03) determines which claim the gateway authorizes on. (→ P03, P12)
  3. Rate limiting as fairness and as defense. rate-limit-by-key per subscription smooths bursts and bulkheads tenants; a quota-by-key caps the monthly total for billing; a flood gets 429 + 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)
  4. 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)
  5. Observability of the edge. Every 401/403/429/5xx is 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

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.
  • 401403429, and each triggers a different client action. 401 re-authenticate, 403 get more scope, 429 back 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/GET are idempotent and safe to retry on a timeout; POST is 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. Skip aud and you're a confused deputy; conflate 401 and 403 and 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.py and your lab.py
  • You can recite the gateway pipeline order and say what each stage's failure code is
  • You can state the 401/403/429 rule 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-After it owes a client
  • You can explain scp vs roles authorization 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/429 trio everyone gets backwards), pagination and content negotiation, Azure API Management and its policy pipeline, authentication-vs-authorization at the edge (validate-jwt then 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

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, OPTIONS are safe: a crawler can fetch them freely. (A GET that deletes something is a famous bug class — search engines have wiped admin panels by following "delete" links that were GETs.)
  • Idempotent — performing it N times leaves the resource in the same state as performing it once. This is the property that decides retryability.
MethodSafeIdempotentMeaningMay a client retry on timeout?
GETread a resourceyes
HEADread headers onlyyes
PUTreplace a resource wholesaleyes — second PUT = same state
DELETEremove a resourceyes — already-gone stays gone
POSTcreate / process (non-idempotent)no — may create a duplicate
PATCH❌ (not guaranteed)partial updateonly 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:

CodeNameWhen
400Bad Requestmalformed syntax, unparseable JSON, missing required field
401Unauthorizedun-authenticated — no/invalid/expired token (see Ch. 8)
403Forbiddenauthenticated but not authorized — wrong scope/role
404Not Foundno such resource / route
405Method Not Allowedroute exists, but not for this method
406 / 415Not Acceptable / Unsupported Media Typecontent-negotiation mismatch
409Conflictthe write conflicts with current state (e.g. optimistic-concurrency)
422Unprocessable Entitysyntactically valid but semantically wrong
429Too Many Requestsrate/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 / limitGET /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 discards offset rows).
  • Cursor / keysetGET /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 a next cursor (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:

  1. Signature — recompute/verify with the issuer's public key (selected by the token's kid from the issuer's JWKS). Until this passes, the payload is attacker-controlled JSON — read nothing from it.
  2. iss — the token was issued by your Entra tenant, not some other issuer.
  3. 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.
  4. 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.
  • rolesapp 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 scp401; 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.

CodeLiteral meaningReal meaningCorrect client action
401Unauthorizedun-authenticated — no/invalid/expired tokenre-authenticate (get a fresh token)
403Forbiddenauthenticated, not authorized — valid token, wrong scopeacquire more scope/role (re-auth won't help)
429Too Many Requestsauthenticated + authorized, over budgetback off for Retry-After seconds, then retry

Why the distinction is load-bearing:

  • Return 403 when you meant 401 (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. Return 401 when you meant 403 and 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/401 when you meant 429 and the client won't back off — it treats throttling as a permanent failure. 429 with a Retry-After is a cooperative signal: "you're fine, just slow down for N seconds." A well-behaved client honors it and the system self-stabilizes; a 429 without Retry-After leaves 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:

  1. the methods are equal (GET /orders and POST /orders are different operations — method is part of the key), and
  2. the segment counts are equal (/users/42/orders does not match /users/{id}), and
  3. 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 againstrequired_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):

  1. 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). None on no match. Test param extraction, most-specific-wins, method dispatch, and 404.
  2. authorize (Ch. 7–8). Fail closed: not-a-dict / not-valid / no scp401; scp not a superset of required_scopes403; else 200. Test all three, including the superset-is-fine case and the "needs two scopes, has one" 403.
  3. TokenBucket.allow (Ch. 9). Lazy refill min(capacity, tokens + elapsed·refill); reject a backward now; consume one if ≥ 1; else retry_after = (1 − tokens)/refill. Test allow-to-capacity-then-429, the partial-token retry_after, refill after elapsed time, and the cap.
  4. RateLimiter — one bucket per client, lazily created. Test per-client isolation (Alice draining doesn't affect Bob).
  5. handle (Ch. 11). The four-stage pipeline; pin the order with the 404-before-401 and 401-before-429 tests; emit Retry-After (whole seconds, rounded up, min 1) on 429.

Success Criteria

You are ready for the next phase when you can, from memory:

  1. Explain REST vs RPC and why uniform methods give every layer free, doc-less semantics.
  2. List which HTTP methods are safe and idempotent, and explain why idempotency decides whether a client may retry on a timeout.
  3. Name the status-code families and the 4xx/5xx retry rule.
  4. State the 401/403/429 distinction and the client action each should trigger, and give the production failure mode of getting 401-vs-403 backwards.
  5. Draw the APIM policy pipeline (inbound → backend → outbound → on-error) and place validate-jwt, rate-limit-by-key, and routing in the right section.
  6. Separate authentication (validate-jwt: signature/iss/aud/exp) from authorization (scp/roles), and explain scp-vs-roles (delegated vs app, by grant).
  7. Implement a token bucket and compute its Retry-After, and distinguish rate-limit from quota.
  8. Justify the gateway pipeline order (404 before 401, 401 before 429).

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. Authorizevalidate-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 Requests and its intended use with Retry-After.
  • RFC 6749 — The OAuth 2.0 Authorization Framework (2012) — §3.3 scope (the space- delimited scp convention), the grant types, and §5.2 the invalid_token/ insufficient_scope errors behind 401/403.
  • RFC 6750 — OAuth 2.0 Bearer Token UsageWWW-Authenticate and the error="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) vs roles (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

CodeMeansClient should
200/201/204OK / Created / No Contentproceed
400malformed requestfix the request, don't retry as-is
401un-authenticated (no/bad/expired token)re-authenticate
403authenticated, not authorized (wrong scope)get more scope (re-auth won't help)
404no such route/resourcecheck the URL
409conflict (optimistic concurrency)re-read, re-apply
415/406wrong Content-Type / Acceptfix media type
429over rate/quotaback off Retry-After seconds
5xxserver 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 403 on 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

ThingNumber / rule
Idempotent methodsGET 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 buckettokens = min(capacity, tokens + elapsed·refill); spend 1 per call
capacity vs refillcapacity = burst you tolerate; refill = sustained rate you guarantee
Rate-limit vs quotarate-limit = calls/second (burst); quota = calls/day (fairness/billing)
Clock skew (JWT exp)Entra default leeway ~±300 s (P03)
scp vs rolesscp = delegated (user present, space-delimited); roles = app-only (daemon)
Pagination defaultcursor/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 401 for an authorization failure. The client thinks the token's bad, refreshes, retries the same forbidden call, loops. Fix: it's a 403. The code is the contract.
  • "We double-charged customers during the outage." → retry-on-5xx logic replayed a non-idempotent POST /charge. Fix: idempotency keys, so a replay is a no-op.
  • "/users/me returns the wrong user." → route matching picked /users/{id} with id="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-jwt checked the signature but not aud. Confused deputy. Always check aud.
  • "Our 429s aren't helping." → no Retry-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.
  • scp vs roles — delegated (a user is present) vs app-only (a daemon).
  • Cursor pagination — opaque "last key I saw"; stable under writes, unlike offset.
  • Content negotiationAccept/Content-Type dance; mismatch is 406/415.

9. Beginner mistakes that mark you in interviews

  1. Saying "REST" but designing RPC (POST /api/doStuff) — no resources, no uniform methods.
  2. Confusing 401 and 403 (the single most common edge bug, and it causes real incidents).
  3. Returning 200 with an error in the body — breaks every layer that reads the status.
  4. Retrying a POST on a timeout without an idempotency key — duplicate writes.
  5. 429 without Retry-After — clients retry too fast and amplify the overload.
  6. Checking the JWT signature but not aud — the confused-deputy breach.
  7. Rate-limiting before authenticating — letting unauth probes drain rate budget.
  8. Route matching that lets /users/{id} shadow the literal /users/me.
  9. 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: every 4xx/5xx is 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/roles authorization 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-After arithmetic 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 is 401-vs-403, the token-bucket rate limiter with a correct Retry-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 of if statements — 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/me shadows /users/{id}), or None (→ 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-delimited scp is missing a required scope — not allowed), or 200 (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 is min(capacity, tokens + elapsed × refill), consume one if available, and on empty retry_after = (1 − tokens) / refill. Deterministic — time is injected as now, never read from a clock.
  • RateLimiter — one bucket per client_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 a Response(status, body, headers). The order is the contract.

Key concepts

ConceptWhat to understand
Path-template matching{id} matches one segment and binds it; literal matches only itself; arity must match
Most-specific-winsamong matches, the template with the most literal segments wins (/users/me/users/{id})
Method dispatchGET /orders and POST /orders are different operations; method is part of the key
401 vs 403401 = authentication failed (no/invalid token) → re-auth; 403 = authorization failed (valid token, wrong scope) → re-login won't help
Fail closedabsent/malformed token, or a required scope you can't prove, denies — never "allow on doubt"
scp containmentrequired scopes must be a subset of the token's granted scopes; a superset of grants is fine
Token bucketcapacity = burst, refill_per_sec = sustained rate; tokens accrue continuously, one spent per request
Retry-Afteron 429, tell the client exactly how long until one token accrues so it backs off precisely
Pipeline order404 before 401 (no op to authorize); 401/403 before 429 (don't let probing cost rate budget)

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py walks a 404, 401, 403, 200, a 429 after the bucket drains, and a 200 after refill
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_auth returns 404 and not 401 even 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_429 returns 401 and not 429 — 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-403 rule from memory: 401 is "I don't know who you are" (re-authenticate); 403 is "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_token expects 0.25 — with 0.5 of a token accrued and a 2/s refill, one full token is (1 − 0.5) / 2 = 0.25 s away.
  • 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 labReal Azure API Management
Gatewayan APIM instance (the gateway) fronting one or more backends
Routean operation on an API (method + URL template), grouped into a product
match_routeAPIM'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_scopesthe required-claims block of validate-jwt, or a check-header/choose on the scp claim
TokenBucket / RateLimiterthe 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 429APIM emits Retry-After on throttling so clients back off correctly
the pipeline orderAPIM's policy pipeline: inbound (auth, rate-limit, transform) → backendoutboundon-error
Response headersAPIM 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 → issaudexp/nbf → claim), so authorize runs the full check and still returns 401/403. Now the gateway is end-to-end correct.
  • roles (app-only) authorization: add app-role authorization alongside scp — a client-credentials token carries roles, not scp; require either scp ⊇ delegated or roles ⊇ 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 that 429 can come from either and that they answer different questions (burst vs monthly fairness).
  • 429 headers: emit X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit- Reset alongside Retry-After, the GitHub/Stripe convention well-behaved clients read.
  • Pagination + content negotiation: add a GET /users that returns a page with a next cursor, and honour Accept: application/json; return 406 on an unsupported media type and 415 on an unsupported request Content-Type.
  • PATCH vs PUT idempotency: add both and test that two identical PUTs converge to one state (idempotent) while two POSTs 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 a validate-jwt + rate-limit-by-key policy, and watch a missing scope return 403 and a flood return 429 with Retry-After on real traffic.

Interview / resume

  • Talking points: "Walk me through what an API gateway does to a request, in order." / "401 vs 403 — when does each fire and why does the distinction matter operationally?" / "Implement a token bucket and tell me what Retry-After should 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/403 fail-closed distinction, and a continuous-refill token-bucket rate limiter emitting Retry-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: receive locks a message invisible for LockDuration; the consumer must complete (remove), abandon (release now), or dead_letter it before the lock expires. Lock expiry → redelivery + DeliveryCount++; once a message has been delivered MaxDeliveryCount times 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.
  • 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 idempotent PUT, now on the data plane.
  • Sessions / FIFO — ordering is not free: a session locks all messages of one SessionId to 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)

FieldValue
GoalBuild 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
ConceptsPeek-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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. Order-processing pipeline — an OrderPlaced command flows Service Bus queue → inventory consumer → payment consumer, each step idempotent on OrderId, 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.)
  2. Reactive blob-processing fan-outMicrosoft.Storage.BlobCreated → Event Grid → N subscribers (thumbnailer, virus-scan, indexer), each filtered by subject prefix, 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.)
  3. 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.
  4. 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? lengthen LockDuration? add idempotency?). (Builds on P13 observability, P14 reliability.)
  5. 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

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 toHow
P03 — Entra IDa 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 & AuthZan Event Grid subscriber is a webhook — a JWT-validated, idempotent HTTP handler exactly like the gateway taught
P11 — Serverless / Functionsa 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 Identitythe secret-free identity a consumer uses, and where a payment/credential in a workflow step is fetched
P13 — ObservabilityDeliveryCount, active/dead-letter message counts, and retry rates are the alerts that catch a stuck consumer before customers do
P14 — Reliabilityretry + backoff + jitter, the DLQ as a failure bulkhead, and the at-least-once/idempotency contract are the reliability primitives applied to messaging
P15 — Capstonethe 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++; after MaxDeliveryCount deliveries 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 idempotent PUT, 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 MaxDeliveryCount buys you.

Deliverables Checklist

  • Lab 01 implemented; all tests pass against solution.py and your lab.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_until counts 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

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 BusEvent GridEvent Hubs
Shapebroker (queue)router (pub/sub)log (stream)
Modelconsumer pulls & locksservice pushes to handlerconsumer reads at an offset
Unita command/messagea notification ("X happened")a telemetry event
Throughputthousands–tens of thousands/svery high fan-outmillions/s
Orderyes, via sessionsnoper partition
Replayno (settled = gone)noyes (within retention)
Dedupyes (MessageId window)no (idempotent consumer)consumer-side (offset)
Dead-letteryes (DLQ entity)yes (to storage)n/a (offsets)
Pay fornamespace + opsper million operationsthroughput units / partitions
Use whenmust-not-lose commands, order, transactions, workflows"react to X" fan-out at scale, no consumer to runhigh-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:

  1. receive(now) pulls the next visible message and locks it: it becomes invisible to every other consumer until lock_until = now + LockDuration. The broker increments DeliveryCount (this delivery counts, whether or not it succeeds). The consumer gets the message and a lock token.
  2. The consumer now has until lock_until to 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). DeliveryCount already 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.
  3. 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 delivery DeliveryCount climbs 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:

GuaranteeMechanismFailure modeWhen
at-most-onceno retry; fire-and-forgetmay lose (consumer crash = gone)metrics you can drop; ReceiveAndDelete
at-least-onceretry until acknowledgedmay duplicatethe practical default; PeekLock, Event Grid
exactly-once-effectat-least-once + idempotent/dedup consumera duplicate is a no-opfinancial, 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 deterministicnow 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):

  1. Message.__init__ — validate message_id (non-empty string or None), session_id (string or None), scheduled_for (number or None); init delivery_count=0, dead_letter_reason=None. (Ch. 3)
  2. Broker.__init__ + send — validate construction; enforce the session_id rule (required iff sessions_enabled); implement duplicate detection (drop a repeat message_id inside dedup_window) and _expire_dedup. (Ch. 6)
  3. receive + _next_receivable_index — peek-lock: call tick, 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)
  4. 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_count boundary right. (Ch. 4, 5)
  5. tick — reclaim every lock with lock_until <= now (the off-by-one), in stable order, routing each through _requeue_or_dead_letter. (Ch. 4)
  6. deliver_with_retry — first attempt at now, back off per schedule (clamp at the last interval), treat a raised handler as failure, dead-letter when retries exhaust. (Ch. 9)
  7. 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:

  1. Name the three event-plane shapes (broker/router/log = Service Bus/Event Grid/Event Hubs) and the one decision that picks between them.
  2. Draw the peek-lock state machine: receive→lock→complete/abandon/dead-letter; lock-expiry→ redeliver + DeliveryCount++; MaxDeliveryCount → DLQ.
  3. Explain the lock-expiry off-by-one (now >= lock_until is expired) and why abandon and lock-expiry reach the same redelivery effect two ways.
  4. 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.
  5. Explain duplicate detection (producer side) vs idempotency (consumer side) as the two halves of exactly-once-effect.
  6. Say what sessions cost (throughput / bounded parallelism) and when ordering is required.
  7. Sketch an Event Grid CloudEvent, its type/subject filter, its backoff-retry policy, and its dead-letter-to-storage target.
  8. 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 BusService 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 GridEvent 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 HubsEvent Hubs overview, partitions, consumers, and offsets (for the Service Bus vs Event Hubs contrast): https://learn.microsoft.com/azure/event-hubs/
  • Azure Logic AppsLogic Apps overview, triggers, actions, and connectors, Standard vs Consumption: https://learn.microsoft.com/azure/logic-apps/
  • Azure FunctionsService 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

ThingNumber
Service Bus LockDurationdefault 30s, max 5 min (renew for longer handlers)
MaxDeliveryCountdefault 10 → then dead-letter
Duplicate-detection windowup to 7 days
Message TTLdefault 14 days (then DLQ)
Message size256 KB Standard, up to 100 MB Premium
Sessions / transactionsPremium-friendly; sessions = FIFO + 1 active consumer per SessionId
Event Grid deliveryat-least-once, exponential backoff, retry to ~24h, then dead-letter to blob
Event Hubs retention1–90 days; order per partition; scale = throughput units / partitions
Strong delivery truthtrue 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 raise LockDuration (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/CloudEvent id the 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

  1. Saying "exactly-once" without "-effect" — instant tell you haven't built a real consumer.
  2. Reaching for Event Hubs because "high throughput" sounds good, then needing DLQ/sessions.
  3. Forgetting the consumer must be idempotent because delivery is at-least-once.
  4. Using ReceiveAndDelete (at-most-once) for anything that matters, then losing messages.
  5. No DLQ alert — the poison messages pile up silently until someone notices the gap.
  6. Enabling sessions "to be safe" and tanking throughput for a stream that didn't need order.
  7. A handler slower than LockDuration with no lock renewal → mysterious constant redelivery.
  8. 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 mutable delivery_count and dead_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 repeat message_id inside dedup_window is silently dropped) and scheduled messages (scheduled_for in the future is not receivable yet).
    • receive(now) -> (msg, lock_token) | Nonepeek-lock: the message goes invisible until now + 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_count already climbed), or move to the DLQ with a reason.
    • tick(now) — when a lock expires (now >= lock_until), redeliver and increment delivery_count; once a message has been delivered max_delivery_count times 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: call handler(event), and on failure (falsy return or a raised exception) retry after fixed exponential-backoff intervals; after max_retries are 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

ConceptWhat to understand
Peek-lockreceive locks a message invisible; you must settle it or the lock expires
Lock expiry → redeliverynow >= lock_until ⇒ redelivered, DeliveryCount++ (the off-by-one)
Max-delivery → DLQdelivered at most max_delivery_count times, then dead-lettered
Abandon vs lock-expiryabandon redelivers now; expiry redelivers after the lock — both ++
Duplicate detectiona repeat MessageId inside the window is dropped (idempotent producer)
Scheduled messagesenqueued but not receivable until scheduled_for
SessionsFIFO per session_id, one active consumer per session
Event Grid retryat-least-once + fixed exponential backoff, then dead-letter to storage
Delivery semanticsat-most / at-least / exactly-once-effect (the only one that exists)

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs a six-part worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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.py and against solution.py.
  • You can explain why test_lock_expiry_at_exact_boundary_is_expired treats now == lock_until as expired (the lock has elapsed, so the message is back).
  • You can explain why, with max_delivery_count=2, the poison message in test_max_delivery_count_moves_to_dlq_via_abandon reaches the DLQ at delivery_count == 2 — "delivered at most twice," not "twice then one more."
  • You can explain why test_session_single_active_consumer skips 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_retry treats a raised exception identically to a False return, 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 labIn 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 tokenPeekLock receive mode (vs ReceiveAndDelete = at-most-once)
complete / abandon / dead_letterCompleteMessageAsync / AbandonMessageAsync / DeadLetterMessageAsync
tick redelivery on lock expirythe broker's lock timeout → automatic redelivery, DeliveryCount++
max_delivery_count → DLQMaxDeliveryCount (default 10) → the entity's $DeadLetterQueue
dedup_windowRequiresDuplicateDetection + DuplicateDetectionHistoryTimeWindow
scheduled_forscheduled messages (ScheduledEnqueueTimeUtc / ScheduleMessageAsync)
sessions_enabled / session_idRequiresSession + SessionId (FIFO, single accepted-session consumer)
deliver_with_retry schedule + dead-letterEvent Grid at-least-once push, exponential-backoff retry policy (to ~24 h / max attempts), then dead-letter to a storage account
classify_deliverythe 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 RenewMessageLockAsync to extend a lock for a long-running handler. The lab models the consequence of not renewing (expiry → redeliver).
  • No deferral / transactionsDefer/ReceiveDeferredMessage and atomic send-and-complete transactions are described in WARMUP.md but not implemented (an extension below).
  • Logical time only — Azure uses UTC wall-clock with lock-duration arithmetic; the lab injects integer now so every test is deterministic. The arithmetic is identical.

Extensions

  • Lock renewal: add renew_lock(token, now, extend_by) that pushes lock_until forward (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 Broker subscriptions behind a Topic that fans a published message to every subscription whose SQL/correlation filter matches — the Event Grid eventType/subject filter, but pull-based.
  • Transactions: a transaction() context that makes complete(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 watch DeliveryCount, 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 DeliveryCount tracking, 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 / TaskCompleted events) 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 the context APIs (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 returns 202 with a statusQueryGetUri; 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)

FieldValue
GoalBuild 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
Conceptstarget-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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. Order-processing pipeline (chaining + fan-out). A serviceBusTrigger function 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)
  2. Async long-running job with polling. An HTTP-triggered starter kicks off a Durable orchestration and returns 202 Accepted with a status URL; the client polls until Completed. 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)
  3. Scheduled monitor with human approval. A timerTrigger starts 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)
  4. Event-driven enrichment at scale. An eventHubTrigger function 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)
  5. 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

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 context APIs (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 (your DeterminismError) 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.py and your lab.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.Now inside 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

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:

  1. 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).
  2. Scale to zero: if backlog == 0, desired = 0. No work, no instances, no bill.
  3. 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_step models 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_instances and 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:

LeverWhat it doesCost
Premium / Flex always-readykeep N pre-warmed instances; first event is never coldyou pay for the warm instances
Smaller deploymentfewer/lighter dependencies → faster loadengineering time
Keep-alive / warmup triggerping the function to keep one instance warma little compute
Move latency-critical paths off scale-to-zeroput the hot path on Premium, keep batch on Consumptionsplit 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.

PlanBillingScale-to-zero / cold startPre-warmedVNetUse it for
Consumptionper execution (GB-s + count)yes / yesnolimitedspiky/intermittent background work; cheapest when idle
Flex Consumptionper second + always-readyyes / mitigatedalways-ready instancesyesthe modern default: fast scale, optional warm instances, network isolation
Premium (Elastic)per pre-warmed + per usedno (min instances) / noyesyeslatency-sensitive, long-running, VNet-bound workloads
Dedicated (App Service)the App Service plan you already pay forno scale-to-zeron/a (always on)yeswhen 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 orchestratorWhy it breaks replayUse instead
DateTime.Now / datetime.utcnow()different time each replayctx.current_utc_datetime (recorded once, replayed)
Guid.NewGuid() / uuid4()different id each replayctx.new_guid() (deterministic)
random()different branch each replaycompute randomness in an activity
HTTP / DB / file I/O directlydifferent result + side effects on every replaycall an activity (runs once, recorded)
Task.Delay / sleepnot durable; lost on restartctx.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 → C
    

    Use 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 Completed
    

    Use 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 wait
    

    Use 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:

  1. desired_instances / scale_decision (Ch. 3–4) — validate inputs, compute ideal = ceil(backlog / target_per_instance) (but 0 when backlog == 0 — scale to zero), clamp to [0, max_instances], then move from current by at most max_scale_out_step in either direction without overshooting. scale_decision compares the result to current. Test scale-to-zero, the max clamp, and the per-step burst limit.
  2. Trigger / Binding / FunctionDef / dispatch (Ch. 2) — Trigger.matches is same-type and (no filter or matching filter); Binding validates direction; dispatch routes to the one matching function, runs it, and fans the return value out to every out binding. Test routing, output bindings, and the no-match error.
  3. run_orchestrator — the replay loop (Ch. 6–7) — walk a cursor over a copy of history. For each yielded CallActivity: if a recorded event exists at the cursor, verify it matches (kind/name/input) — mismatch → DeterminismError — and return its recorded result; else execute activities[name], append a HistoryEvent, return the fresh result. Drive the generator with next/.send. Test chaining's final result and the identical call order on partial vs full replay.
  4. 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 .send back a list of results. Test the aggregation and identical replay.
  5. Deterministic current_utc() (Ch. 7) — a yielded CurrentUtc replays 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.
  6. 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:

  1. Explain trigger vs binding, and why a function has exactly one trigger.
  2. 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.
  3. 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.
  4. Pick Consumption vs Flex vs Premium vs Dedicated for a workload and justify it with the latency/cost/isolation/VNet tradeoff.
  5. 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.
  6. State why an orchestrator must be deterministic and list four things forbidden inside it (clock, GUID, random, I/O) and the ctx API that replaces each.
  7. 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

🛸 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 backlogdesired = 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

ThingValue
Consumption instance cap~200 (Windows; Linux ~100)
Consumption timeoutdefault 5 min, max 10 min
Premium/Dedicated timeoutup to unbounded (Premium default 30 min)
Scaleon backlog (queue depth / lag), not CPU
Scale to zeroConsumption + Flex (→ cold start)
Cold start~100s of ms to several seconds, dominated by dependency size
Pre-warmed / no cold startPremium (always min instances), Flex (always-ready)
Event-trigger parallelism ceilingalso partition/session count of the source
Durable orchestrator ruledeterministic — no DateTime.Now/random/Guid/I/O
Durable stateevent-sourced history in Azure Storage / Netherite / MSSQL
Replay-safe timecontext.current_utc_datetime (NOT DateTime.Now)
Replay-safe idcontext.new_guid() (NOT Guid.NewGuid())
Replay-safe waitcontext.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 the ctx API. Classic: someone added a log line with DateTime.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/sleep instead 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 scalingceil(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

  1. Saying "Functions autoscales" with no idea it scales on backlog, not CPU.
  2. 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).
  3. Calling a Durable orchestrator "a long-running process in memory" (it's replayed).
  4. Putting DateTime.Now / Guid.NewGuid() / random() / an HTTP call in the orchestrator — the determinism violation that corrupts replay.
  5. Thinking replay re-runs completed activities (it replays their recorded results).
  6. Using Task.Delay/sleep for a long wait instead of a durable timer.
  7. Forgetting the Event Hub / Service Bus parallelism ceiling is partition/session count.
  8. Storing the DB connection string in app settings instead of using a Managed Identity.
  9. 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 DeterminismError the 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 most max_scale_out_step instances from current per decision (gradual scale-out, no stampede). Backlog 00 instances (scale-to-zero). The scale_decision returns "scale_out" / "scale_in" / "hold".
  • Trigger / Binding / FunctionDef / dispatch(...) — the trigger/binding model. A Trigger (type + optional filter) decides which function an event invokes; the function's declared output bindings collect its return value. dispatch routes 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 that yields CallActivity instructions and receives results back. The engine replays the generator from the start against a recorded history: 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 (yield a list of CallActivity → a list of results) and a deterministic current_utc() (served from history, never the wall clock) are built in. A divergent replay raises DeterminismError.

Key concepts

ConceptWhat to understand
Target-based scalingdesired = ceil(backlog / target_per_instance); the controller chases a per-instance work target, not CPU
Scale to zerobacklog 00 instances; the next event pays a cold start to spin one up (the Consumption tradeoff)
Per-step burst clampthe runtime adds a bounded number of instances per decision — protects the source and downstream from a stampede
Max instancesthe plan's hard ceiling (Consumption ~200, others configurable); ideal is clamped to it
Trigger vs bindingthe trigger invokes the function (one per function); bindings declaratively wire inputs/outputs
Orchestrator = generatorit yields instructions and is replayed; it must be a pure function of its history
Event-sourced replaythe engine re-runs the orchestrator from history every turn; recorded steps replay, the next step executes
Determinismno datetime.now, no random, no I/O outside activities — or the replay diverges and Durable throws
DeterminismErrorthe yielded call no longer matches history (name/input/kind) → the orchestrator is non-deterministic
Deterministic timectx.current_utc() reads a recorded timestamp from history, so every replay sees the same "now"

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py runs a worked example (scale table, chaining, fan-out/fan-in, a caught divergence)
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_backlog is 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_step matters — a 1000-message burst doesn't instantly become 1000 instances; the controller climbs +max_scale_out_step per decision so it doesn't stampede the queue or your database.
  • You can explain why test_replay_partial_vs_full_same_call_order is 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_activities is 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_replay exists — a random() branch or a datetime.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 labReal Azure
desired_instancesthe 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)
backlogqueue message count (Storage/Service Bus), Event Hub lag (unprocessed events per partition), the depth the controller reads
scale to 0Consumption and Flex Consumption scale to zero when idle; the next trigger pays a cold start to spin an instance up
max_instancesthe plan's instance cap — Consumption defaults to ~200, Premium/Flex/Dedicated are configurable
max_scale_out_stepthe controller adds instances gradually (a bounded number per ~second), not all at once
Triggerthe 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_orchestratorthe Durable Task Framework replay loop inside the Durable extension
CallActivitycontext.df.callActivity(name, input) (JS) / context.call_activity(name, input) (Python) / context.CallActivityAsync (.NET)
historythe orchestration's history table in the Durable storage provider (Azure Storage / Netherite / MSSQL) — the event-sourced log replayed on each event
CurrentUtccontext.current_utc_datetime — the deterministic replay-safe clock
DeterminismErrorDurable's non-determinism detection — it logs/raises when replayed code produces a different action than history recorded
fan-out / fan-inthe 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 a RaiseEvent API and a timeout (a CreateTimer raced against the event with when_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 new an orchestrator + activity + HTTP starter; deploy with az functionapp create; trigger it and watch the history table in the storage account fill with TaskScheduled / TaskCompleted events as it replays. Then put a random.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:

  1. 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.254 for 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).

  2. 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 valueKey Vault Secrets User). An Owner who 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-fingered delete undoable for a retention window, and purge protection means not even a compromised admin can make a deletion permanent on demand.

  3. 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) → vault path 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 by client_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 aud claim. https://vault.azure.net for Key Vault, https://storage.azure.com for Storage, https://management.azure.com for 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), never O(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)

FieldValue
GoalBuild 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)
Conceptsmanaged 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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)
  2. 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)
  3. Event-driven secret rotation. Key Vault emits a SecretNearExpiry event 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)
  4. 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)
  5. 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

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). An Owner who 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 onlyO(#DEKs), never O(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.py and your lab.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 Owner of 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

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-assignedUser-assigned
Lifecyclecreated with the resource, deleted with ita standalone Azure resource; independent lifecycle
Countexactly one per resourceattach one identity to many resources
Identity =the resource itselfa shareable identity object
Selected by(implicit — the resource's own)its client_id (or object id / resource id)
Use whenthe identity's life is the resource's; nothing else needs itseveral 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:

ObjectWhat it isKey data-plane ops
Secretan opaque value (string, ≤ 25 KB) — API keys, connection stringsget, set, list, delete
Keyan asymmetric/symmetric key you use without exportingencrypt/decrypt, wrapKey/unwrapKey, sign/verify
Certificatean X.509 cert + its key, with a lifecyclecreate, 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 elapsesby 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
Tenancymulti-tenantsingle-tenant
FIPS 140-2Level 2 (Premium: HSM-backed keys)Level 3
Key extractableno (Premium HSM keys)never
Use whenmost apps, secrets, certsregulated / 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/SecretExpired events; 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):

  1. Identity + system_assigned/user_assigned (Ch. 2) — the validation is the lesson: system-assigned has no client_id; user-assigned requires one. principal_key is the stable cache key.
  2. 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, bump imds_calls. token_is_valid checks audience and expiry. Make test_token_cached_before_expiry_same_token and ..._refreshed_after_expiry_... pass — that pair is the caching contract.
  3. keystream/enc/dec (Ch. 5) — SHA-256 counter mode; dec is just enc. Get the round-trip green first.
  4. generate_dek/wrap_dek/unwrap_dek — thin wrappers over enc/dec; the naming is the teaching (a DEK wrapped under a KEK).
  5. envelope_encrypt/envelope_decrypt/rotate_kek (Ch. 5–6) — the payoff. Make test_kek_rotation_data_ciphertext_object_unchanged pass and understand why it's the whole point: rotation rewraps the DEK only.
  6. KeyVault versioning + RBAC (Ch. 4) — set_secret appends a version; get_secret returns latest or a pinned version and denies by default unless the principal holds a data-plane role (Owner is not one).
  7. Soft-delete state machine (Ch. 7) — deleterecover (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:

  1. State the one principle ("don't store the secret") and name secret-zero.
  2. 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.
  3. Pick system- vs user-assigned with a concrete reason for each (RBAC grant amplification; deploy ordering).
  4. Explain why an Owner of a Key Vault gets a 403 reading a secret (control vs data plane).
  5. Choose RBAC over access policies and say why (inheritable, deny-aware, auditable).
  6. Explain envelope encryption (DEK/KEK/wrap) and why KEK rotation is O(#DEKs), not O(bytes) — and never re-encrypts the data.
  7. 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 Owner get 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-assignedUser-assigned
Lifecycleborn & dies with the resourcestandalone resource
Count1 per resource1 identity → many resources
Selected byimplicitits client_id
Pick whenidentity == resource's lifeshare 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 (OwnerBlob Data Reader).

4. The numbers you'll actually use

ThingNumber
IMDS address169.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 size25 KB
Soft-delete retention7–90 days (default 90)
Purge protectionirreversible once on; blocks purge until retention elapses
RBAC rolesKey Vault Secrets User (read), Secrets Officer (read/write), Administrator, Crypto User
FIPSKey Vault 140-2 L2 (Premium HSM-backed keys); Managed HSM 140-2 L3
KEK rotation costO(#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.net and called Storage. A token is scoped to one resource. (Lab: test_token_audience_scoping.)
  • "My Owner gets 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.254 token 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

  1. Saying "managed identity = no token" (there is a token — a JWT; what's gone is the stored secret).
  2. Defaulting to system-assigned and never reaching for user-assigned's RBAC-grant economy.
  3. Granting Owner/Contributor and expecting to read secrets (data plane needs its own role).
  4. Re-encrypting data to rotate a key (the whole point of envelope encryption is you don't).
  5. Putting a secret in an env var / config / git instead of Key Vault + a reference.
  6. Shipping a vault without soft-delete + purge protection on anything that matters.
  7. 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-finger delete is 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.py says so loudly. Never ship it.

What you build

  • Managed-identity token flowIdentity (system-assigned vs user-assigned selectors), InstanceMetadataService.acquire_token(resource, identity, now) returning an AccessToken with expires_on = now + ttl, scoped to a resource (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 encryptionkeystream(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 centerpiece rotate_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 modelKeyVault with versioned secrets (set_secret appends a version; get_secret returns latest or a pinned version), RBAC default-deny (get_secret(..., principal, role_assignments) raises unless the principal holds a data-plane read role — Owner does not count), and the soft-delete state machine: delete_secret → recoverable until now + retention; recover_secret restores; purge_secret permanently removes but raises when purge protection is on inside the retention window.

Key concepts

ConceptWhat to understand
Managed Identityan Entra service principal Azure manages; code holds no secret and gets a token from IMDS
System vs user-assignedsystem-assigned is tied to one resource's lifecycle (no client_id); user-assigned is standalone and attachable to many (selected by client_id)
IMDSthe 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 + skewrefetch only near expiry (a refresh_skew_s before expires_on) — a hot loop must not DoS IMDS
Control plane vs data planemanaging the vault (Owner) is control plane; reading a secret is data plane and needs a data-plane role
RBAC default-denyno matching role assignment → deny, not error; least privilege is the default posture
Versioned secretseach set creates an immutable version; old versions stay readable so in-flight clients survive a rotation
Envelope encryptionencrypt data with a fast DEK; wrap the DEK with a KEK in the vault/HSM; store (ciphertext, wrapped_dek)
Rewrap-on-rotaterotating the KEK only unwraps+rewraps the DEK — O(#DEKs), not O(bytes); the data ciphertext is untouched
Soft-deletea delete is recoverable for a retention window; the bytes are not gone yet
Purge protectioninside retention, nobody (not even an admin) can force-purge — the anti-ransomware/insider guarantee

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs a worked example (token caching, envelope + rotation, RBAC deny→allow, soft-delete→recover, blocked purge)
test_lab.pythe proof — run it red, make it green (29 tests)
requirements.txtpytest 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_token is the line that keeps a hot loop from DoS-ing IMDS: the SDK serves the cached token until it is near expiry; only test_token_refreshed_after_expiry_new_token and test_token_refreshed_within_skew_window actually hit the endpoint again.
  • You can explain why test_token_audience_scoping matters: a token is scoped to one resource; reusing a vault.azure.net token against storage.azure.com returns 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_unchanged is 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_allow denies even an Owner: 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_protection is 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 labReal Azure
InstanceMetadataServicethe 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 skewIMDS 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 rolesAzure 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 secretsthe 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 SecretVersionevery 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_dekKey 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_dekin real envelope encryption the DEK is fresh CSPRNG bytes per object; here it's seed-derived only so tests are deterministic
soft-delete / recover / purgeKey Vault soft-delete (now always on; default 90-day retention) and purge (az keyvault secret purge)
purge_protection blocks purgepurge protection — when on, soft-deleted objects (and the vault) cannot be purged until retention elapses; irreversible once enabled
Managed HSM framingfor 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 FederatedCredential path — 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_valid checks iss/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 Certificate object with issue, 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 with DefaultAzureCredential + SecretClientno secret in the code. Then az keyvault secret delete / recover / try purge with 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 Owner of 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 pipelinewhere | 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 of summarize (group-and-reduce) and bin (floor onto an absolute time grid) are the load-bearing two.
  • Aggregations & cardinalitycount / sum / avg / min / max / dcount / percentile. How a percentile is computed (nearest-rank vs interpolation vs Kusto's T-Digest approximation), why dcount is approximate (HyperLogLog) at scale, and why a high-cardinality by dimension 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) and operation_ParentId (the parent span), so a join on operation_Id reassembles 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 rulesaggregate(metric over a lookback window) <operator> threshold → action group. The window/lookback, the aggregation granularity, the operator boundary (gt vs ge), and the for/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)

FieldValue
GoalBuild 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
ConceptsKQL 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. The latency-regression huntrequests | summarize p95=percentile(DurationMs, 95), n=count() by bin(TimeGenerated, 5m), Service, then render timechart. When a release regresses p95, this is the query that names the service and the minute. (Lab 01 directly.)
  2. The distributed-trace reassembly — a slow checkout spans the API gateway (P09), Service Bus (P10), a Function (P11), and Key Vault (P12). join requests to dependencies on operation_Id to attribute the latency to the exact hop — the query that ends the "it's not my service" standoff.
  3. Blast-radius gaugeexceptions | 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.
  4. The cost autopsyUsage | summarize GB=sum(Quantity) by DataType to find which table is eating the ingestion bill, then a sampling/retention/transform decision — the FinOps half of observability (P15 capstone).
  5. 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 monitor one-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. dcount on 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 (gt vs ge), and the debounce decide whether it's a signal or a 3 a.m. lie.
  • operation_Id is how a request becomes a story across services; a join on 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 PUT and 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. A Reader on 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 ... by is a line item — aggregate first, sample deliberately, never group by a GUID.
  • Approximate by design. percentile (T-Digest) and dcount (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/ge boundary, the for/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_Id propagated 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.py and your lab.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 dcount dimension is expensive and what you do instead
  • You can describe a metric alert vs a log (scheduled-query) alert, the for/debounce knob, and the gt-vs-ge boundary trap
  • You can draw the operation_Id / operation_ParentId correlation 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

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:

PillarWhat it isThe question it answersAzure store
Metricsnumeric time series (a number sampled over time)"is it up? how loaded? trending where?"Azure Monitor Metrics
Logstimestamped structured records (rows with fields)"what exactly happened to this request/resource?"Log Analytics workspace (queried with KQL)
Tracescausally-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 with operation_Id so 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).

MetricLog (Log Analytics)
stored aspre-aggregated time seriesraw typed rows
granularity~1 minute, fixedper-event
cardinalitylow (a few dimensions)high (any field)
queryfast, limitedKQL, arbitrary
latency to queryseconds (near-real-time)seconds–minutes (ingestion lag)
costcheap$/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.

OperatorWhat it doesLab function
wherekeep rows matching a predicatewhere(rows, predicate)
projectkeep / rename a subset of columnsproject(rows, columns)
extendadd computed columnsextend(rows, **computed)
order by / sort bystable sort by a columnorder_by(rows, key, desc)
top n bythe n largest/smallest by a columntop(rows, n, by, desc)
take / limitthe first n rowstake(rows, n)
countnumber of rows (a scalar)count(rows)
summarize ... bygroup-and-reducesummarize(rows, aggs, by)
joincorrelate two tables on a keyjoin(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:

  1. Compute the group key for each row — the tuple of the by columns. With by Service, bin(TimeGenerated, 5m) the key is (Service, 5-min-bucket).
  2. Bucket rows by that key (a hash map: key → list of rows).
  3. Reduce each bucket: apply every aggregation to that bucket's column values.
  4. 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 - 200sbelow 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(...)), then dcount the 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 a summarize ... 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 ┘
  1. Signal — the metric or the result column of a log query.
  2. Aggregationavg / min / max / count / sum / percentile over the window.
  3. Lookback window — only data in [now - window, now] is considered. Too short → noisy and gappy; too long → slow to detect.
  4. Operator + thresholdgt / lt / ge / le against the threshold. The boundary is a real bug surface: a value of exactly 300 against threshold 300 fires under ge but not under gt. Pick wrong and you either miss the breach or page on the boundary forever (the lab tests this).
  5. 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 (the for window) 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):

  1. Row-shapingwhere(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.
  2. Ordering/windowingorder_by (stable sort), top (= order_by[:n], default desc), take, count. Validate n >= 0.
  3. bin_time(value, width)math.floor(value/width)*width; validate width > 0. Test the grid (299 → 0, 300 → 300) and the negative case (Ch. 5).
  4. 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 and 0 ≤ p ≤ 100 (Ch. 6).
  5. summarize(rows, aggregations, by) — bucket by the by-tuple, reduce each group, emit in sorted key order. Support count/sum/avg/min/max/dcount/percentile; by=[] → one global row. Validate aggregations non-empty, funcs known, by-columns exist (Ch. 5–6).
  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 if leftouter and no match. Validate kind and key presence (Ch. 8).
  7. 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).
  8. Make Table construct (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:

  1. Name the three pillars (metrics/logs/traces), the Azure store for each, and the question each answers — and state when you reach for which.
  2. 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.
  3. Compute a nearest-rank percentile by hand, contrast it with interpolation, and explain why Kusto uses an approximate T-Digest at scale.
  4. Explain why dcount and high-cardinality by dimensions blow up cost, and what you do instead.
  5. Explain inner vs leftouter join and the correctness trap of using inner when you're hunting for what's missing.
  6. Draw the operation_Id / operation_ParentId correlation and contrast head vs tail sampling with the cost/fidelity argument.
  7. State the alert anatomy (agg(window) <op> threshold → action group), the gt-vs-ge boundary trap, and the for/debounce knob.
  8. 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*:join requeststodependenciesonoperation_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 functionspercentile/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 percentile at scale).
  • HyperLogLog — Flajolet et al., HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm (behind dcount).
  • Google SRE BookService 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 traceparent propagation 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)

PillarStoreAnswersReach for it when
MetricsAzure Monitor Metrics"is it up / how loaded / trending?"confirming that something's wrong, fast & cheap
LogsLog Analytics (KQL)"what happened to this request?"investigating the what and why
TracesApp 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)*wabsolute grid, not relative to your data; that's why series from different queries line up.

3. The aggregations + the gotchas

AggDoesGotcha
count()rows in group
sum/avg/min/max(Col)the obvious foldavg 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 oncecheaper 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

ThingNumber
Metrics granularity1 minute, pre-aggregated, near-real-time, cheap
Logs billing$/GB ingested + retention → cardinality is the bill
dcount accuracy~1.6% standard error (HyperLogLog, default)
percentileT-Digest approximation; exact doesn't scale
99.9% SLO error budget0.1%43 min outage / 30 days
99.99% SLO4.3 min / 30 days (one extra nine = 10× stricter)
ago() units1m 1h 1d (and 5m for the canonical bin)
Adaptive sampling defaulthead 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. avg is 80 ms; p99 is 4 s. Switch to percentile(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 by it.
  • "The alert pages every few minutes and we've started ignoring it." → window too short, no debounce, or gt flapping right at the boundary. Add a for/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 requests to dependencies on operation_Id, summarize the 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

  1. Reaching for logs to answer "is it up" (that's a metric) or a metric to debug "this one request" (that's a log).
  2. Reporting avg latency instead of a percentile — instantly junior.
  3. summarize ... by a high-cardinality column (userId, requestId, raw URL) and being surprised by the cost and the slow query.
  4. Saying percentile/dcount are exact — they're approximate at scale by design.
  5. Building an alert with no debounce/for and no no-data policy, then drowning in pages.
  6. Ignoring the gt-vs-ge boundary, so a value exactly at the threshold either never pages or always does.
  7. inner join when hunting for what's missing (it silently drops the very rows you wanted).
  8. 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_Id correlation 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), the bin() that turns continuous time into 5-minute buckets, an inner/leftouter join, 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 so t.where(...).summarize(...).top(...) reads like the KQL pipe (|). Every method returns a new Table (pure; nothing is mutated). Underneath are the real engine — the operator functions, usable directly on any list[dict].
  • Row-shaping operatorswhere(rows, predicate) (filter), project(rows, columns) (keep/rename columns; takes a list or a {new: old} dict), extend(rows, **computed) (add columns from callable(row) -> value).
  • Ordering / windowingorder_by(rows, key, desc) (stable sort), top(rows, n, by, desc) (the n biggest/smallest), take(rows, n), count(rows).
  • bin_time(value, width) — the bin() function: floor(value / width) * width, flooring continuous timestamps onto an absolute grid so rows in the same interval group together inside summarize.
  • 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 the by columns and reduce each group with count / 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, inner and leftouter.
  • AlertRule + evaluate_alert(rows, rule, *, now) — the alert: window-filter rows to [now - window, now], aggregate the metric, compare to the threshold with gt/lt/ge/le. Empty window → does not fire (no data cannot breach).

Key concepts

ConceptWhat to understand
KQL is a pipeline of pure functionseach operator takes rows, returns new rows; left-to-right `
where / project / extendfilter 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 ... bygroup 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/maxthe obvious folds over a group's column values
dcountdistinct count — cardinality. The dimension that, when high (userId, requestId), blows up both query cost and storage
percentilenearest-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 leftouterinner 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 thresholdagg(metric over [now-window, now]) <op> threshold. The window/lookback and the operator boundary (gt vs ge) are where false pages live
Determinismsorted group order, stable sort, nearest-rank percentile, copies not mutations — same input → same bytes, so tests are exact

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs a worked example over a request log
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_answers asserts percentile([1..100], 95) == 95 exactly: nearest-rank takes rank = 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_grid floors 299 → 0 and 300 → 300: bin() lands rows on an absolute grid of width-sized buckets, which is why two queries with the same bin(Timestamp, 5m) line up even though they never coordinated.
  • You can explain why test_summarize_count_by_group_is_deterministic matters — 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_ge is the off-by-one interviewers probe: a metric of exactly 300 against threshold 300 does not fire under gt but does under ge. 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_fire is deliberate: an empty window has no metric to breach, so it returns False — 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 labReal Azure
a Table (list[dict])a table in a Log Analytics workspacerequests, 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
dcountKQL dcount(...) — an HyperLogLog approximate distinct count at scale; exact count(distinct ...) doesn't scale to billions of rows
join kind=inner / leftouterthe KQL join kind=inner / kind=leftouter operators (Kusto also has innerunique, rightouter, fullouter, leftanti, …)
AlertRule + evaluate_alerta 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 parameterthe 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 a make_series(rows, agg, on_time, step, range) that emits a dense time series (one bucket per step across the whole range, zero-filled), the way KQL make-series does 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 + dependencies tables sharing operation_Id, then join them to compute, per request, the sum of downstream dependency time — the classic "where did the latency go?" trace query.
  • Alert debounce (for window): extend evaluate_alert to require the threshold be breached for N consecutive evaluations before firing (Azure's "number of violations" / for clause) — 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); then az monitor scheduled-query create an 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 percentile scale to billions of rows — what's T-Digest costing you?" / "Why does high-cardinality dcount (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 does operation_Id reconstruct 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-rank percentile), 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 as 1 − ∏(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-After because 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 least n of n+k nodes) 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. Honoring Retry-After (on 429 / 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)

FieldValue
GoalBuild 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
Conceptsserial 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
Steps1. 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 Testpytest 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 bulletBuilt 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:

  1. 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)
  2. 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-After from the throttled service (P09 rate limiting, P10 Service Bus throttling). (→ P09, P10)
  3. 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_threshold and reset_timeout, and wire it to the dead-letter path (P10) for the messages it sheds. (→ P10, P11)
  4. 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 diagnose tree, fix the zone link (P06), and add a synthetic DNS- resolution probe so the next one alarms before customers notice (P13). (→ P06, P13)
  5. 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

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-eventif 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-After obeys 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.py and your lab.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

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:

AvailabilityNinesDowntime / monthDowntime / year
0.99two432 min (7.2 h)~3.65 days
0.999three43.2 min~8.76 h
0.9999four4.32 min~52.6 min
0.99999five0.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 random is 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_threshold consecutive 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_timeout elapses, 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:

SignatureLikely causeFirst action
429throttling / rate limit / quotahonor Retry-After, back off + jitter, spread load, check service & subscription limits
403authz — RBAC / data-plane role / Key Vault / denycheck the role and scope, the data-plane role (Storage Blob Data Reader, not just Reader), Key Vault access policy/RBAC, deny assignments, Conditional Access
401authn — bad/expired/missing tokencheck iss/aud/exp/scp claims, the Managed Identity token acquisition, clock skew
404not found / routing / wrong instancecheck URL/path, resource id, API version, whether a Private Endpoint resolves you to the wrong instance
503dependency unavailablecircuit-break, fail fast, check its health/SLA and your composite SLA, shed load
502 / 504gateway / upstreamcheck backend health probes, the pool, upstream timeouts (Front Door/APIM couldn't reach a healthy backend)
symptom: name resolution failedDNS / Private Endpointcheck 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: timeoutdependency slow/stuckcircuit-break, check dependency health/SLA and connection pool, ensure retries use backoff + jitter
symptom: connection refusedconnectivityNSG/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:

  1. The 403 from 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 — and diagnose({"http_status": 403}) points you straight at the data-plane role.
  2. 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 diagnose sends 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):

  1. SLA math (Ch. 3–4) — _validate_avail (reject outside [0,1] and NaN), then serial_sla (the product ∏), redundant_sla (1 − ∏(1 − a)), composite_sla (collapse each list-valued hop with redundant_sla, then serial-multiply), downtime_minutes_per_month ((1 − A) × minutes), and n_plus_k_sla (sum the binomial terms with math.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.2 min.
  2. 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). Then RetryBudget (can_retry/record/remaining, raise on overspend) and retry_after_seconds (case-insensitive Retry-After → honor it; else full_jitter).
  3. Circuit breaker (Ch. 7) — the CircuitBreaker state machine with injected now: allow_request (Closed→pass, Open→reject until reset_timeout then →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.
  4. 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:

  1. 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.
  2. Define an availability zone vs a region pair, and say when you reach for zone redundancy vs multi-region — with the cost each adds.
  3. Compose a serial chain's SLA (∏ aᵢ), explain why it's below every hop, and identify which hop to make redundant first.
  4. Compose redundant replicas (1 − ∏(1 − a)), show two 0.99 → 0.9999, and explain why same- zone replicas break the independence the formula assumes.
  5. 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.
  6. State the three safety catches on a retry — backoff, jitter, budget — and why idempotency is the precondition, and when to honor Retry-After.
  7. Draw the circuit-breaker state machine and name what each of the three states prevents.
  8. 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

🛸 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)

AvailabilityNinesDowntime / month (43,200 min)
0.99two432 min (7.2 h)
0.999three43.2 min
0.9999four4.32 min
0.99999five~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

ThingNumber / rule
Two 0.99 replicas0.9999 (each independent replica ~doubles the nines)
Three 0.99 replicas0.999999
N+1 (2 of 3 @ 0.99)0.999702
Region has≥ 3 availability zones (enabled regions)
Inter-zone latencytypically < 2 ms RTT (sync replication is fine)
Backoffmin(cap, base · 2^attempt), attempt 0-indexed
Full jitteruniform(0, backoff) — bounds 0 ≤ j ≤ backoff
Throttled status429 (+ Retry-After); sometimes 503
Retry preconditionidempotency (no idempotency → don't retry mutations)
Breaker statesclosed → 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.
  • "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 SLA1 − ∏(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 jitteruniform(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

  1. Quoting a 99.9% SLA without composing it across the path (the system is below every hop).
  2. Saying "redundant" without independent failure domains (same-zone replicas aren't redundant).
  3. Retrying with no jitter (thundering herd) or no budget (retry storm), or retrying a non- idempotent mutation (double effect).
  4. Ignoring Retry-After and fighting the throttle on your own schedule.
  5. Confusing a circuit breaker with a retry (breaker says stop, retry says again).
  6. Letting more than one probe through in half-open (re-stampede on recovery).
  7. Reading a 403 as authentication (it's authz) — debugging the wrong layer.
  8. Forgetting the control-plane vs data-plane role split behind a Storage 403.
  9. Calling a Private-Endpoint DNS failure a "Storage outage" instead of a Private DNS zone link.
  10. 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 → DB chain.
  • 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)429 rate limiting and message throttling are where Retry-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, honor Retry-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 compositionserial_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 with redundant_sla, then serial- multiply), downtime_minutes_per_month(avail) ((1 − A) × 43200), and n_plus_k_sla(p, n_required, total_nodes) — the binomial probability that at least n_required of total_nodes are up. All validate availabilities into [0, 1].
  • Retry backoff & jitterbackoff_delay(attempt, base, cap) = min(cap, base · 2^attempt); full_jitter(attempt, base, cap, rng) = rng.uniform(0, backoff_delay(...)) with an injected, seeded rng (so the bounds are testable deterministically); a token-style RetryBudget(cap) (can_retry() / record() / remaining()) that stops retries when the budget is exhausted; and retry_after_seconds(headers, ...) that honors a Retry-After header when present and falls back to full-jitter backoff otherwise.
  • The circuit breakerCircuitBreaker(failure_threshold, reset_timeout) with states closed / open / half_open, driven by on_success(), on_failure(now), and allow_request(now) — all with injected now so the machine is deterministic. Closed → Open after failure_threshold consecutive failures; Open fails fast until reset_timeout elapses, then → Half-Open (one probe); a Half-Open success → Closed, a failure → Open.
  • The triage decision treediagnose(signature) maps a failure signature (an http_status and/or a symptom) 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

ConceptWhat to understand
Serial SLAA = ∏ aᵢ; every hop must be up, so the composite is below every hop (more hops, lower SLA)
Redundant SLAA = 1 − ∏(1 − aᵢ); the group is down only if all replicas are down — each independent replica ~doubles the nines
Composite chaina 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 backoffmin(cap, base · 2^attempt); spaces retries, the cap bounds the wait
Full jitteruniform(0, backoff); de-synchronizes the thundering herd — needs a seeded, injected rng
Retry budgeta cap on total retries so they can't amplify a blip into a retry storm
Retry-Afterthe server telling you exactly how long to back off (on 429/503) — honor it over guessing
Idempotencytwice == once; the precondition for any safe retry of a mutation
Circuit breakerclosed → open (fail fast) → half-open (one probe) → closed; the opposite of a retry
Triage treefailure signature (status + symptom) → likely cause + first action; symptom beats status

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete 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.pythe proof — run it red, make it green
requirements.txtpytest 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_hop asserts 0.997002999 for 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_availability gives 0.9999 for 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_value is 0.972 for 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_seeded uses a seeded rng — reliability logic that calls un-seeded random is untestable; the bound 0 ≤ jitter ≤ backoff is the whole point and you can only assert it deterministically.
  • You can explain why test_retry_budget_exhaustion is 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_reopens matters — 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_status exists — 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 labReal Azure
serial_slathe 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_slazone-redundant / multi-instance services — a load balancer in front of N replicas; the group is up unless all replicas are down
composite_slaa real chain of redundant hops: zone-redundant Front Door → APIM → Function → zone-redundant SQL
downtime_minutes_per_monththe downtime budget behind an SLA percentage; the foundation of an SRE error budget and burn-rate alerting (P13)
n_plus_k_slaN+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_jitterevery Azure SDK's retry policy — exponential backoff with jitter (the default transient-fault handling)
RetryBudgeta retry budget / retry-storm guardrail (Polly's circuit/bulkhead, SDK retry caps) so retries can't amplify an outage
retry_after_secondshonoring the Retry-After header Azure services return with 429 (Storage, Cosmos DB, ARM, APIM, Entra throttling)
CircuitBreakerthe Circuit Breaker cloud design pattern (Polly in .NET, Resilience4j elsewhere) — fail fast, then probe
diagnosethe 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_sla to 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 diagnose to Azure Monitor (P13): feed real AzureDiagnostics rows (status codes + exception messages) through diagnose to 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 403 from 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-After honoring, 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":

  1. 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.
  2. 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)

FieldValue
GoalBuild 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.
Conceptscomposing 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.
Steps1. 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 Testpytest 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 bulletBuilt 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:

PhaseEngine built thereHow the capstone uses it
P00WAF tradeoff + SLA/cost modelerthe five-pillar scorer; composite_sla, monthly_cost, the ADR habit
P01ARM deployment graph + idempotent PUTthe request the gate fronts: an idempotent control-plane write
P02Terraform plan/diff enginethe IaC the deploy request comes from; what-if is the gate's dry run
P03OAuth2/OIDC/JWT validatorcheck_identity — iss/aud/exp/nbf, the confused-deputy aud guard
P04RBAC + Policy evaluatorcheck_authorization (deny-wins) + check_policy (deny/audit effects)
P05landing-zone inheritance resolverthe management-group scope the policies + RBAC are assigned at
P06NSG + route engine, Private Linkthe public_network posture flag; private endpoints for PII
P07OCI registry + AKS schedulerthe container image the serverless/AKS workload deploys
P08CI/CD DAG + OIDC federationthe pipeline that calls the gate; OIDC = no stored secret
P09API gateway (JWT scope + rate limit)the edge that verifies the signature before the gate sees claims
P10Service Bus broker + Event Grid retrythe async tier in the availability chain; at-least-once + DLQ
P11Functions scale controller + Durablethe compute the deployment provisions; scale-to-zero (FinOps)
P12Key Vault + Managed Identitycheck_security_posture — MI vs secret, Key Vault, CMK
P13KQL engine + alert rulesthe observability the scorecard's findings feed; DLQ-depth alerts
P14SLA composition + retry + circuit breakercomposite_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.py and your lab.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

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:

PillarThe question it asksThe engine that answers it
Reliabilitydoes it stay up, and recover?SLA composition, retry, circuit breaker (P14)
Securityis identity the perimeter, secrets safe, data private?JWT/RBAC/Policy (P03–P04), Key Vault/MI (P12)
Cost Optimizationis every dollar buying something the workload needs?the bill model + FinOps levers (P00, Ch. 5)
Operational Excellencecan a small on-call run, deploy, and debug it?CI/CD, observability, runbooks (P08, P13)
Performance Efficiencydoes 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 $$

AvailabilityDowntime / monthWhat it takes
99% (two nines)~432 min (~7.2 h)single instance, best effort
99.9% (three)~43 minzone-aware, health probes
99.99% (four)~4.3 minzone-redundant + fast failover
99.999% (five)~26 smulti-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:

TierRPORTOHowCost shape
Backup/restorehourshoursrestore from geo-redundant backupcheapest; storage only
Active-passive (warm)minutesminutessecondary warm, async geo-replication, Front Door failoversecondary infra + cross-region egress
Active-active~0secondsboth 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_policy enforces.
  • 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):

  1. Data modelAvailabilityLink (validate [0,1], non-empty), CostLine (reject negatives), Deployment (validate required fields, normalize tuples).
  2. check_identity — iss, then aud (string or list — the confused-deputy guard), then nbf <= now+leeway, then exp > now-leeway. Fail closed; raise only on malformed args, return False on a merely-bad token.
  3. check_authorization — deny-wins first (any in-scope deny matches → False), then default-deny over role assignments. Segment-aware scope_contains (the rg/rg2 trap) and *-wildcard action matching.
  4. check_policy — walk the condition tree (field equals/notEquals/in/exists under allOf/anyOf/not); collect "<effect>: <name>" violations; has_deny_violation is the reject test.
  5. 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.
  6. SLA + costcomposite_sla (redundant within a link, serial across links), downtime_minutes_per_month, monthly_cost.
  7. well_architected_score — derive each pillar from the engines; clamp; add overall.
  8. 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:

  1. Run the WAF review method on a cold design prompt in five steps and produce a stage-by-stage scorecard.
  2. 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).
  3. Name the six FinOps levers and, for each, the single number that decides whether to pull it.
  4. State RPO and RTO for the three DR tiers and which workload buys which.
  5. Draw the secure event-driven serverless reference architecture from memory, naming the control-plane and data-plane components and which phase built each.
  6. Explain why the admission gate returns 401-before-403 and why a low security score is advice but a deny policy is a gate.
  7. 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 Foundationfinops.org — the discipline, the Framework (Inform → Optimize → Operate), and the cost-lever vocabulary in Chapter 5.
  • Azure Architecture Centerlearn.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 SLAsazure.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)

PillarThe questionScored from
Reliabilitystays up & recovers?composite SLA → nines
Securityidentity-perimeter, secrets safe, private?posture (MI/private/KV/CMK)
Costevery dollar buys something needed?the bill vs reference
Operational Excellencecan a small on-call run it?less to rotate/leak/watch
Performancemeets 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

ThingNumber
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 discountup 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 deny policy 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; Owner has no DataActions. 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

  1. Designing only the data plane; forgetting identity/RBAC/policy/landing zone.
  2. Quoting an SLA without composing the dependency chain.
  3. Claiming "exactly-once delivery" (it doesn't exist across a network boundary).
  4. "We'll use Kubernetes" with no workload numbers to justify it over Functions/ACA.
  5. Public PaaS for PII; secrets in app settings instead of Key Vault + Managed Identity.
  6. No rollback story, no DR test, no cost estimate — and no ADR.
  7. Treating the security score as a gate (it's advice) or a deny policy 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 / nbf with 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: and audit:) from a condition tree (P04); has_deny_violation is 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 policy deny (403-like); else admit with a Well-Architected scorecard and a generated one-paragraph ADR.

Key concepts

ConceptWhat to understand
Gates vs adviceidentity/authz/policy are hard rejects; the scorecard is reviewer advice
Fail closedevery gate denies by default — a malformed or missing claim is a no, not a maybe
Gate orderingauthentication (401) before authorization (403) before policy (403)
Deny winsa deny assignment overrides any role grant (P04) — the system's last word
Confused deputywrong aud is a reject — a token for service B must not work at service A
The review is a functionthe WAF review is repeatable: score each pillar from the evidence, write the ADR
The ADR is the artifactthe gate's output is a durable, auditable paragraph — not a vibe

Files

FilePurpose
lab.pyskeleton with # TODO markers and full signatures/docstrings
solution.pycomplete reference; python solution.py runs the worked example
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_authorization expects 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_identity is the claim half of what Microsoft Entra ID + APIM do on every call: validate iss/aud/exp/scp. Real validation also verifies the RS256 signature against the tenant's JWKS (by kid) first — we assume that's done at the edge and document it.
  • check_authorization is Azure RBAC evaluation: in-scope role assignments, Actions − NotActions with wildcards, and deny assignments win — the same algorithm ARM runs before any control-plane PUT.
  • check_policy is Azure Policy at create/update: the policyRule condition tree → effect (deny blocks the write; audit flags it). The "no public PaaS for PII" and "require HTTPS" denies are straight out of a real landing-zone baseline.
  • composite_sla / monthly_cost are the Well-Architected Reliability and Cost arithmetic you do before the review — serial ∏Aᵢ, redundant 1−∏(1−Aᵢ), the first-order bill.
  • admit is the shape of an Azure Policy deny + 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's resolve_tradeoff idea) so the ADR can say "admitted, but cost-weighted reviewers should note…".
  • Add a DR dimension: an active_active: bool flag 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_scopes to check_identity and a per-action scope requirement, so the gate also enforces delegated-scope authorization (P09) — not just RBAC.
  • Make admit return a 409-style "needs remediation" soft-reject for audit violations above a threshold, distinct from the hard deny reject.

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.