diff --git a/README.md b/README.md
index 71db00d..dbc575b 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Each sample is a self-contained project with its own README, Azure CLI scripts a
| Sample | Description |
|--------|-------------|
| [Function App and Storage (.NET)](./samples/function-app-storage-http/dotnet/README.md) | A gaming scoreboard built on Azure Functions (isolated worker): HTTP triggers record player scores in Table Storage, publish messages to Queue Storage and write game-session summaries to Blob Storage, all against the emulated storage account. |
-| [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | A minimal Python Function App answering `/{name}`, published behind an Azure Front Door (Standard) profile so requests reach the function through the Front Door endpoint; deployable to real Azure or to the emulator. |
+| [Function App and Front Door (Python)](./samples/function-app-front-door/python/README.md) | Two Python Function Apps serving a small *Catalog* API, published through an Azure Front Door (Standard) endpoint: the edge picks an origin by priority, matches the more specific of two routes, caches what the origin allows, and runs a rule set that stamps a response header, rewrites `/shop` to `/catalog` and redirects a retired path without calling the origin at all. |
| [Function App and Managed Identities (Python)](./samples/function-app-managed-identity/python/README.md) | A serverless text processor: an Azure Functions app reads text blobs from an `input` container, converts them to uppercase and writes the result to an `output` container, authenticating to the storage account with a managed identity instead of keys. |
| [Function App and Service Bus (.NET)](./samples/function-app-service-bus/dotnet/README.md) | An Azure Functions app on an App Service plan that exchanges messages through Service Bus queues: an HTTP trigger sends greetings and a queue trigger consumes them, connecting with either a connection string or a managed identity. |
| Web App and CosmosDB for MongoDB API ([Python](./samples/web-app-cosmosdb-mongodb-api/python/README.md), [.NET](./samples/web-app-cosmosdb-mongodb-api/dotnet/README.md)) | The *Vacation Planner* single-page web app on an Azure Web App with regional VNet integration, storing activities in the `activities` collection of an Azure Cosmos DB for MongoDB account reached through a private endpoint. |
diff --git a/run-samples.sh b/run-samples.sh
index 62c9f25..ee54b33 100755
--- a/run-samples.sh
+++ b/run-samples.sh
@@ -32,7 +32,7 @@ SAMPLES=(
"samples/servicebus/java|bash scripts/deploy.sh"
"samples/eventhubs/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/run-pipeline.sh"
"samples/eventhubs-eventgrid/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/run-pipeline.sh"
- "samples/function-app-front-door/python|bash scripts/deploy_all.sh --name-prefix testafd|"
+ "samples/function-app-front-door/python|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-front-door.sh"
"samples/function-app-managed-identity/python|bash scripts/user-managed-identity.sh|bash scripts/validate.sh && bash scripts/test.sh"
"samples/function-app-service-bus/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-trigger.sh"
"samples/function-app-storage-http/dotnet|bash scripts/deploy.sh|bash scripts/validate.sh && bash scripts/call-http-triggers.sh"
@@ -201,7 +201,10 @@ if [[ "${1:-}" == "--list" ]]; then
watch=("$path" "$(dirname "$path")/src" "$(dirname "$path")/scripts")
name="${path#samples/}"
else
- watch=("$path/scripts" "$path/src")
+ # A sample's application code lives in src/ or, for the Function App samples, function/;
+ # a change there has to re-run the sample as surely as a change to its scripts. Folders that
+ # do not exist simply never match a changed file.
+ watch=("$path/scripts" "$path/src" "$path/function")
name="${path#samples/}/scripts"
fi
diff --git a/samples/function-app-front-door/python/README.md b/samples/function-app-front-door/python/README.md
index 982dfaf..d01ebf9 100644
--- a/samples/function-app-front-door/python/README.md
+++ b/samples/function-app-front-door/python/README.md
@@ -1,284 +1,206 @@
-# Azure Function App and Azure Front Door (Azure CLI)
-
-This sample creates a minimal Python Azure Function App that responds to `/{name}` and configures Azure Front Door (Standard SKU) to route traffic to it. It can target real Azure or LocalStack's Azure emulation via `lstk az` interception.
-
-## Overview
-
-- **`scripts/deploy_all.sh`**: One script that provisions all scenarios below in a single resource group:
- 1. Basic single-origin routing
- 2. Multiple origins with priority/weight selection
- 3. Route specificity/precedence
- 4. Rules Engine demo (three rules: response header, rewrite, redirect)
- 5. Endpoint enabled/disabled state toggle
-- **`scripts/cleanup_all.sh`**: Deletes the resource group created by `deploy_all.sh`
-
-## Architecture at a Glance (Diagrams)
-The following diagrams visualize each scenario provisioned by `deploy_all.sh`. They help you see the wiring between AFD endpoints, routes, origin groups/origins, and the Function App(s).
-
-### Basic Single-Origin
-
-
-
-**What to notice:** One Endpoint → one Route (`/*`) → one Origin Group → one Origin → Function App.
-
-### Multi-Origin (Priority/Weight)
-
-
-
-**What to notice:** Two Origins in a single Origin Group with explicit `priority` and `weight`. A group-level health probe (HEAD `/`, 120s) gates origin eligibility; selection prefers the lowest priority and distributes by weight among equally prioritized healthy origins.
-
-### Route Specificity
-
-
-
-**What to notice:** Two Routes on the same Endpoint and Origin Group: a catch-all (`/*`) and a specific (`/john`). The most specific matching route should be chosen by the data plane.
-
-### Rules Engine
-
-
-
-**What to notice:** A Route with an attached Rule Set (three rules):
-- **Rule 1**: ModifyResponseHeader on GET → `X-CDN: MSFT`
-- **Rule 2**: UrlRewrite when path begins with `/api` → `/`
-- **Rule 3**: UrlRedirect when path begins with `/old` → `/new` (302 Found)
-
-### Endpoint Enabled/Disabled State
-
-
-
-**What to notice:** The Endpoint's `enabled-state` can be toggled; when Disabled, requests should return a 4xx (e.g., 403). Re-enabling restores normal behavior.
-
-### Notes for LocalStack Runs
+# Front Door and Function Apps: a catalog API published through an edge
+
+This sample demonstrates [Azure Front Door Standard](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-overview) in front of two [Azure Function Apps](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview). The Function Apps serve a small *Catalog* API and are otherwise identical: each one reports its own name in every response, so which origin answered, which route matched and what path the origin was asked for can all be read straight off the body.
+
+Clients only ever call the Front Door endpoint. Between the client and the function, the edge picks an origin by priority, decides which of two routes applies, caches what the origin allows it to cache, and runs a [rule set](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-rules-engine) that stamps a response header, rewrites one path prefix into another and answers a retired path with a redirect without calling an origin at all.
+
+The sample exercises both halves of Front Door on the LocalStack Azure emulator: the control plane (profile, endpoint, origin groups, origins, routes, rule set, rules, purge) and the data plane (routing, origin selection, health probes, caching, the rules engine and the headers the edge adds).
+
+## Architecture
+
+The solution is composed of the following Azure resources:
+
+1. [Azure Resource Group](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-cli): A logical container scoping all resources in this sample.
+2. [Azure Storage Accounts](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview) (two): The Function Apps' runtime storage (`AzureWebJobsStorage`), one each.
+3. [Azure App Service Plan](https://learn.microsoft.com/en-us/azure/app-service/overview-hosting-plans) (Linux, B1): Shared by both Function Apps.
+4. [Azure Function Apps](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview) (Python v2 model), **primary** and **secondary**: the *Catalog* origin, with four HTTP routes — `GET /api/catalog/{item}` (cacheable, `Cache-Control: public, max-age=300`), `GET /api/whoami` (what the origin received, `no-store`), `GET /api/status` and `GET|HEAD /api/health` (the health probe target). An `ORIGIN_NAME` app setting is the only difference between the two apps.
+5. [Azure Front Door Standard](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-overview) profile:
+ - One **endpoint**, the address clients call.
+ - The **catalog origin group**, holding the primary origin at priority 1 and the secondary as a priority-2 standby, with a [health probe](https://learn.microsoft.com/en-us/azure/frontdoor/health-probes) that sends `HEAD /api/health` every 30 seconds.
+ - The **status origin group**, holding the secondary origin alone.
+ - The **catalog route** (`/*`), which sends traffic to the catalog origin group with [caching](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-caching) switched on and the rule set attached.
+ - The **status route** (`/status`), a more specific pattern pointing at the other origin group, with no caching and no rules.
+ - The **catalogrules** [rule set](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-rules-engine): `stampHeader` (adds `X-Served-By` to every response), `rewriteShop` (`/shop/*` → `/catalog/*` on the way to the origin) and `redirectLegacy` (`/legacy` → `302` to `/status`, answered at the edge).
+
+```mermaid
+%%{init: {"flowchart": {"nodeSpacing": 50, "rankSpacing": 70}}}%%
+flowchart LR
+ client((Client))
+
+ subgraph afd["Front Door Standard profile"]
+ direction TB
+ routes["Routes
/* · /status"]
+ rules["Rule set catalogrules
stampHeader · rewriteShop · redirectLegacy"]
+ cache["Edge cache
on the /* route"]
+ routes --> rules --> cache
+ end
+
+ subgraph origins["Origin groups"]
+ direction TB
+ primary["catalog-origin-group
primary (priority 1)
standby (priority 2)"]
+ secondary["status-origin-group
secondary"]
+ end
+
+ subgraph apps["Function Apps (Python)"]
+ direction TB
+ app1["primary
/api/catalog/{item} · /api/whoami
/api/status · /api/health"]
+ app2["secondary
same code, ORIGIN_NAME=secondary"]
+ end
+
+ client -->|"1: GET /catalog/1"| routes
+ cache -->|"2: on a miss, GET /api/catalog/1
+ X-Forwarded-Host · X-Azure-ClientIP · X-Azure-FDID"| primary
+ primary --> app1
+ secondary --> app2
+ cache -->|"3: 200 + X-Served-By + X-Cache"| client
+ client -. "GET /status: the more specific route" .-> secondary
+
+ style afd fill:#ffffff,stroke:#999999,color:#333333
+ style origins fill:#ffffff,stroke:#999999,color:#333333
+ style apps fill:#ffffff,stroke:#999999,color:#333333
+```
-- The printed test URLs use `*.afd.localhost.localstack.cloud:4566` for AFD and `*.website.localhost.localstack.cloud:4566` for the Function App, so requests flow through the emulator's edge.
+The life of a request: the client calls `GET /catalog/1` on the endpoint → the `/*` route matches, since no more specific pattern does → the rule set runs → the edge looks in its cache, and on a miss picks the healthy origin with the lowest priority number → the route's origin path puts `/api` back on the front of the path and the request goes to the primary Function App as `GET /api/catalog/1` → the response comes back, is stored because its `Cache-Control` allows it, gets `X-Served-By` from the rule set and `X-Cache`/`X-Azure-Ref` from Front Door, and reaches the client. The second identical request never leaves the edge.
## Prerequisites
-- Bash (e.g., Git Bash, WSL, or Linux/macOS shell)
-- Azure CLI installed and logged in (`az login`) for real Azure
-- **Optional**: `lstk` (LocalStack CLI) in PATH to target the emulator via `lstk az` interception
-- `zip` utility in PATH (used for zip deploy to Azure)
-
-## Quick Start
-
-1. **Start the LocalStack Azure emulator**
- ```bash
- # Start the LocalStack Azure emulator
- IMAGE_NAME=localstack/localstack-azure localstack start -d
- localstack wait -t 60
-
- # Route all Azure CLI calls to the LocalStack Azure emulator
- lstk az start-interception
- ```
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [jq](https://jqlang.org/) and `zip`
+- A LocalStack account with a valid `LOCALSTACK_AUTH_TOKEN` (see the [Auth Token guide](https://docs.localstack.cloud/getting-started/auth-token/))
-2. **Deploy against real Azure** (eastus by default):
- ```bash
- bash ./scripts/deploy_all.sh --name-prefix mydemo
- ```
+## Setup
-3. **Deploy against LocalStack emulator**:
- ```bash
- bash ./scripts/deploy_all.sh --name-prefix mydemo --use-localstack
- ```
+Start LocalStack for Azure and point the Azure CLI at it:
-The script prints:
-- Resource group name
-- AFD endpoint hostnames for each scenario and sample URLs (e.g., `https://.z01.azurefd.net/john`)
+```bash
+export LOCALSTACK_AUTH_TOKEN=
+IMAGE_NAME=localstack/localstack-azure localstack start -d
+lstk az start-interception
+az login --service-principal -u any-app -p any-pass --tenant any-tenant
+az account set --subscription 00000000-0000-0000-0000-000000000000
+```
-## Scenarios Deployed by deploy_all.sh
+Every command below is the same against real Azure; sign in with `az login` instead.
-### 1. Basic Single-Origin
-- One Function App, one AFD endpoint with a catch-all route
-- Test URL: printed as `[Basic]` in the output
+## Deployment
-### 2. Multiple Origins (Priority/Weight)
-- Two Function Apps (A primary, B secondary by default), one origin group with priorities/weights
-- Call repeatedly to observe distribution; the function response includes `"from "` to visualize selected origin
+```bash
+bash scripts/deploy.sh
+```
-### 3. Route Specificity
-- One endpoint with two routes pointing to the same origin group: catch-all (`/*`) and a specific (`/john`) route
-- Compare responses for `/john` vs other paths
+The script provisions the resource group, the App Service plan, the two storage accounts and the two Function Apps, deploys the same zip package to both, then creates the Front Door profile, endpoint, origin groups, origins, rule set, rules and routes. It finishes by printing the endpoint URL and a handful of `curl` commands to try.
-### 4. Rules Engine Demo
-Creates a Rule Set with three rules and attaches it to the route:
-- **ModifyResponseHeader** on GET: sets header `X-CDN: MSFT`
-- **UrlRewrite**: when UrlPath begins with `/api`, rewrites to `/`
-- **UrlRedirect**: when UrlPath begins with `/old`, redirects (302 Found) to `/new`
+It is safe to re-run: the Azure resources it creates are either checked for first or created with an idempotent `PUT`.
-If the `az afd rule-set`/`az afd rule` commands are unavailable, the script skips rule creation gracefully.
+## Testing
-### 5. Endpoint Enabled/Disabled State
-Provisions a dedicated endpoint you can toggle with:
```bash
-az afd endpoint update -g --profile-name --endpoint-name --enabled-state Disabled
-az afd endpoint update -g --profile-name --endpoint-name --enabled-state Enabled
+bash scripts/validate.sh
+bash scripts/call-front-door.sh
```
-## Unified Scripts Details
+`validate.sh` walks the whole chain and exits non-zero on any failure:
-### deploy_all.sh (What It Provisions/Tests)
-- Creates one AFD Profile and up to five Endpoints (one per scenario):
- - Basic: single origin, catch‑all route
- - Multi: two origins in one origin group with priority/weight and a HEAD health probe
- - Spec: two routes on one endpoint to demonstrate route specificity (`/*` vs `/john`)
- - Rules: a rules engine Rule Set attached to the endpoint’s route (three rules listed above)
- - State: an endpoint to toggle Enabled/Disabled
-- Creates the necessary Function App(s): one main app for Basic/Spec/Rules/State, and two apps (A/B) for Multi.
-- Publishes the function code with zip deploy (`az functionapp deployment source config-zip`) for both Azure and LocalStack.
-- Prints convenient test URLs for each scenario
-- Writes an environment file for cleanup at: `scripts/.last_deploy_all.env`
+| # | Check | What it proves |
+|---|-------|----------------|
+| 1 | Both Function Apps answer `/api/health`, including on `HEAD` | The origins are up and answer the method the health probe uses |
+| 2 | `GET /catalog/1` through the endpoint | The catch-all route, the priority-1 origin, and the route's origin path |
+| 3 | `X-Served-By` and `X-Azure-Ref` on the response | The rule set ran; the edge stamped its reference id |
+| 4 | `GET /status` | A more specific route wins, and sends the request to a different origin group |
+| 5 | `GET /shop/2` | The `UrlRewrite` rule: the origin is asked for `/api/catalog/2` |
+| 6 | `GET /legacy` | The `UrlRedirect` rule answers `302` at the edge, without calling an origin |
+| 7 | `/catalog/3` twice, then a purge, then a `no-store` path | Caching, `X-Cache`, `Age`, purge, and the origin's power to refuse caching |
+| 8 | `GET /whoami` | `X-Forwarded-Host`, `X-Azure-ClientIP` and `X-Azure-FDID` reach the origin |
+| 9 | Ten requests to an uncached path | Priority is a strict tier: the standby answers none of them |
+| 10 | `GET /catalog/99` | The origin's own `404` passes through the edge untouched |
+| 11 | The endpoint disabled, then enabled again | `enabledState` takes the endpoint out of service and back |
-### deploy_all.sh (How to Run)
+`call-front-door.sh` is the short version: read a catalog item, read it again from the cache, follow the rewrite and the redirect, and print what the origin received.
-**Azure (cloud):**
-```bash
-bash ./scripts/deploy_all.sh --name-prefix mydemo
-```
+### Calling the endpoint by hand
-**LocalStack (emulator):**
```bash
-bash ./scripts/deploy_all.sh --name-prefix mydemo --use-localstack
+ENDPOINT_URL=http://local-catalog-test.afd.azure.localhost.localstack.cloud:4566
+
+# A cacheable response: the first request is a miss, the second a hit
+curl -si $ENDPOINT_URL/catalog/1 | grep -iE "^(HTTP|x-cache|age|x-served-by)"
```
-**Useful flags:**
-- `-p, --name-prefix`: base name used for resources (auto-sanitized to lowercase/digits)
-- `-l, --location`: Azure region (default: `eastus`)
-- `-g, --resource-group`: use a specific RG instead of an auto-generated one
-- `--python-version`: Python runtime for Function Apps (default: `3.11`)
-- **Scenario toggles** (all enabled by default): `--no-basic`, `--no-multi`, `--no-spec`, `--no-rules`, `--no-state`
-
-### deploy_all.sh (Outputs to Expect)
-
-- Resource group name, e.g., `rg--`
-- Scenario endpoints (Azure or LocalStack hosts) and example URLs, e.g.:
- ```
- [Rules] AFD Local Endpoint: https://ep--rules-.afd.localhost.localstack.cloud:4566/john
- ```
-- For LocalStack runs, the function host and AFD local endpoint names will use `*.localhost.localstack.cloud:4566`
-- The script also writes `scripts/.last_deploy_all.env` with variables like:
- - `RESOURCE_GROUP`, `PROFILE_NAME`, `EP_BASIC`, `EP_MULTI`, `EP_SPEC`, `EP_RULES`, `EP_STATE`, `FUNC_MAIN`, `FUNC_A`, `FUNC_B`
- - The Rules Engine rule set name follows the pattern `rs` (alphanumeric), which can be derived from `PROFILE_NAME`:
- ```bash
- BASE="${PROFILE_NAME#afd-}"; RULE_SET="rs${BASE//-/}"
- ```
-
-### cleanup_all.sh (What It Does)
-
-- Deletes the entire resource group created by `deploy_all.sh` using `az group delete --no-wait`
-- Supports two ways to specify the resource group:
- 1. `--env-file ./scripts/.last_deploy_all.env` (recommended after a fresh deploy)
- 2. `-g/--resource-group `
-- Supports `--use-localstack` to intercept the `az` CLI for emulator cleanup
-
-### cleanup_all.sh (How to Run)
-
-**Using the env file created by the deploy:**
-```bash
-bash ./scripts/cleanup_all.sh --env-file ./scripts/.last_deploy_all.env
+```text
+HTTP/1.1 200 OK
+x-served-by: front-door
+x-cache: MISS
```
-**Passing RG explicitly:**
```bash
-bash ./scripts/cleanup_all.sh --resource-group rg--
+# The rules engine rewrites the path before the origin sees it
+curl -s $ENDPOINT_URL/shop/2 | jq '{origin, path, sku: .item.sku}'
```
-**LocalStack cleanup:**
-Add `--use-localstack` to either command above.
-
-## Verifying the Rules Engine Scenario Quickly (LocalStack)
-
-Assume you ran with `--name-prefix mydemo` and got `ep-mydemo-rules-12345`:
+```json
+{
+ "origin": "primary",
+ "path": "/api/catalog/2",
+ "sku": "AFD-002"
+}
+```
```bash
-HOST="https://ep-mydemo-rules-12345.afd.localhost.localstack.cloud:4566"
-
-# 1) ModifyResponseHeader on GET → expect X-CDN: MSFT
-curl -i "$HOST/" | grep -i "^X-CDN:\s*MSFT" || echo "Header X-CDN not present"
-
-# 2) UrlRewrite for /api → /
-curl -i "$HOST/api" | head -n 1
-
-# 3) UrlRedirect for /old → /new
-curl -i -L "$HOST/old" | head -n 5
+# What Front Door tells the origin about the caller and about itself
+curl -s $ENDPOINT_URL/whoami | jq .front_door_headers
```
-## Deploy to Azure (Cloud) and Test
-
-1. **Sign in/select subscription:**
- ```bash
- az login
- az account set --subscription ""
- ```
-
-2. **Run deployment** (avoid `--use-localstack`):
- ```bash
- cd samples/function-app-front-door/python
- bash ./scripts/deploy_all.sh --name-prefix mydemo --location eastus
- ```
-
-3. **Note the printed outputs** (resource group and endpoints) and test as instructed. Allow 2–10 minutes for AFD readiness.
-
-## If You Closed the Terminal and Need Hostnames Later
-
-**Function host:**
-```bash
-az functionapp show -g -n --query defaultHostName -o tsv
+```json
+{
+ "host": "local-catalog-primary-test.azurewebsites.azure.localhost.localstack.cloud:4566",
+ "via": "1.1 Azure",
+ "x-azure-clientip": "127.0.0.1",
+ "x-azure-fdid": "8c7dc56e48154939834f0469a7e5c1bf",
+ "x-azure-requestchain": "hops=1",
+ "x-azure-socketip": "127.0.0.1",
+ "x-forwarded-for": "127.0.0.1",
+ "x-forwarded-host": "local-catalog-test.afd.azure.localhost.localstack.cloud",
+ "x-forwarded-proto": "http"
+}
```
-**AFD endpoint hostname:**
```bash
-az afd endpoint show -g --profile-name --endpoint-name --query hostName -o tsv
+# Empty the cache for a set of paths
+az afd endpoint purge \
+ --endpoint-name local-catalog-test \
+ --profile-name local-catalog-afd-test \
+ --resource-group local-rg \
+ --content-paths '/catalog/*'
```
-**List resources by RG:**
-- Function Apps:
- ```bash
- az functionapp list -g --query "[].{name:name,host:defaultHostName}"
- ```
-- AFD profiles:
- ```bash
- az afd profile list -g --query "[].name"
- ```
-- AFD endpoints:
- ```bash
- az afd endpoint list -g --profile-name --query "[].{name:name,host:hostName}"
- ```
-
-## Common Notes and Troubleshooting
-
-- **Windows users**: Use Git Bash or WSL to run bash scripts
-- **Authentication**: The function trigger is Anonymous; no keys required
-- **Function response**: Returns plain text and echoes `WEBSITE_HOSTNAME` to help testing multi-origins
-- **Application Insights**: Disabled by default via `--disable-app-insights`
-- **Deployment method**: zip deploy via the Azure CLI for both Azure and LocalStack
-- **AFD readiness**: 2–10 minutes typical; check provisioning state:
- ```bash
- az afd endpoint show -g --profile-name --endpoint-name --query provisioningState -o tsv
- ```
-- **Region/runtime**: Change with `--location`/`--python-version` in `deploy_all.sh`
-
## Cleanup
-Delete all resources by removing the resource group (non-blocking delete):
-
-**Using env file:**
```bash
-bash ./scripts/cleanup_all.sh --env-file ./scripts/.last_deploy_all.env
+bash scripts/cleanup.sh
```
-**Or directly:**
-```bash
-bash ./scripts/cleanup_all.sh --resource-group
-```
+## LocalStack notes
+
+- **The endpoint's local address.** Front Door assigns the endpoint a `*.azurefd.net` host name, and the emulator reports one too, but that name only resolves once [LocalStack's DNS server](https://docs.localstack.cloud/aws/capabilities/networking/dns-server/) is in front of the machine. The scripts use the emulator's own alias instead, `http://.afd.azure.localhost.localstack.cloud:4566`, which resolves to `127.0.0.1` without any DNS setup.
+- **Plain HTTP to the origins.** The emulator serves Function Apps over HTTP on port 4566, so the routes forward with `HttpOnly` and do not redirect HTTP to HTTPS. Against real Azure the same script uses `HttpsOnly` and `--https-redirect Enabled`, because `*.azurewebsites.net` is HTTPS-only. This is the only difference in what the script deploys.
+- **The origin's host name and port.** An origin's `--host-name` is a bare host name, so the script splits the `host:4566` the emulator reports and passes the port as `--http-port`. The `--origin-host-header` keeps the port, because that is the name the emulator routes the Function App by.
+- **Cache status values.** The emulator reports `X-Cache: HIT`, `MISS` and `UNCACHEABLE`; Azure reports `TCP_HIT`, `TCP_MISS` and friends. `validate.sh` looks for the word, not the whole value.
+
+## Two Azure details worth knowing
-## Additional Notes
+- **`UrlPath` conditions drop the leading slash.** A rule that should fire on `/shop/2` matches on `shop`, not `/shop`; `UrlRewrite`'s `--source-pattern`, on the other hand, keeps it (`/shop`). A rule with a leading slash in the match value is accepted, stored and silently never matches.
+- **`az afd rule create` has two spellings.** Up to Azure CLI 2.83 the `afd` commands are part of the CLI and take one flattened condition and action per rule (`--match-variable`, `--action-name`, …). From 2.85 they live in the [`cdn` extension](https://github.com/Azure/azure-cli-extensions/tree/main/src/cdn), which takes `--conditions` and `--actions` in its own shorthand syntax, and spells the route's rule sets and caching differently too. `deploy.sh` detects which one is installed and uses it.
-- Azure Front Door is a global resource; the script uses `Standard_AzureFrontDoor` SKU and links the route to the default domain of the endpoint
-- The function removes the `/api` prefix so you can call `/john` directly
-- The deployment uses zip deploy; because the function has no heavy dependencies, it should work without additional build steps. If you add dependencies that require native builds, consider using the Azure Functions Core Tools for publishing
+## What this sample does not cover
+
+[Custom domains](https://learn.microsoft.com/en-us/azure/frontdoor/standard-premium/how-to-add-custom-domain) and their certificates, [WAF policies and security policies](https://learn.microsoft.com/en-us/azure/web-application-firewall/afds/afds-overview), [private link origins](https://learn.microsoft.com/en-us/azure/frontdoor/private-link), and the older [classic Front Door](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-overview) and [classic CDN](https://learn.microsoft.com/en-us/azure/cdn/cdn-overview) profiles. The emulator implements all of them; see the [Front Door coverage page](https://docs.localstack.cloud/azure/services/front-door/) for what each one supports.
## References
-- [LocalStack for Azure Documentation](https://docs.localstack.cloud/azure/)
-- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
-- [lstk GitHub repository](https://github.com/localstack/lstk)
+- [Azure Front Door documentation](https://learn.microsoft.com/en-us/azure/frontdoor/)
+- [Routing architecture](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-routing-architecture) and [route matching](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-routing-methods)
+- [Rules engine actions](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-rules-engine-actions) and [match conditions](https://learn.microsoft.com/en-us/azure/frontdoor/rules-match-conditions)
+- [Caching with Azure Front Door](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-caching)
+- [How Front Door forwards requests to origins](https://learn.microsoft.com/en-us/azure/frontdoor/front-door-http-headers-protocol)
+- [LocalStack for Azure: Front Door](https://docs.localstack.cloud/azure/services/front-door/)
diff --git a/samples/function-app-front-door/python/function/.python_packages/requirements.txt.md5 b/samples/function-app-front-door/python/function/.python_packages/requirements.txt.md5
deleted file mode 100644
index 4808fdf..0000000
--- a/samples/function-app-front-door/python/function/.python_packages/requirements.txt.md5
+++ /dev/null
@@ -1 +0,0 @@
-1B2M2Y8AsgTpgAmY7PhCfg==
\ No newline at end of file
diff --git a/samples/function-app-front-door/python/function/function_app.py b/samples/function-app-front-door/python/function/function_app.py
new file mode 100644
index 0000000..8e06f88
--- /dev/null
+++ b/samples/function-app-front-door/python/function/function_app.py
@@ -0,0 +1,98 @@
+"""The origin behind Azure Front Door: a small catalog API plus a health endpoint.
+
+Every response says which of the two Function Apps answered and what path the origin was asked
+for, so the Front Door behaviours the sample demonstrates -- origin selection, route matching, URL
+rewriting -- can be read straight off the body. The caching behaviour is the origin's to decide:
+the catalog sets a ``Cache-Control`` Front Door can honour, everything else says ``no-store``.
+"""
+
+import json
+import os
+from urllib.parse import urlparse
+
+import azure.functions as func
+
+app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)
+
+#: Which Function App this is, from an app setting the deployment script sets. The two apps are
+#: identical apart from this value, which is what makes origin selection observable.
+ORIGIN_NAME = os.environ.get("ORIGIN_NAME", "unknown")
+
+CATALOG = {
+ "1": {"sku": "AFD-001", "name": "Edge cache mug", "price": 12.5},
+ "2": {"sku": "AFD-002", "name": "Origin group hoodie", "price": 48.0},
+ "3": {"sku": "AFD-003", "name": "Rules engine notebook", "price": 7.25},
+}
+
+#: The headers Front Door adds on its way to the origin. Echoed by /whoami so a reader can see
+#: what arrives at an origin that sits behind an edge.
+FRONT_DOOR_HEADERS = (
+ "host",
+ "via",
+ "x-azure-clientip",
+ "x-azure-socketip",
+ "x-azure-fdid",
+ "x-azure-requestchain",
+ "x-forwarded-for",
+ "x-forwarded-host",
+ "x-forwarded-proto",
+)
+
+
+def json_response(body: dict, status_code: int = 200, cache_control: str = "no-store"):
+ return func.HttpResponse(
+ json.dumps(body, indent=2),
+ status_code=status_code,
+ mimetype="application/json",
+ headers={"Cache-Control": cache_control},
+ )
+
+
+def origin_path(req: func.HttpRequest) -> str:
+ """The path this Function App was asked for, which is not always the one the client sent."""
+ return urlparse(req.url).path
+
+
+@app.route(route="health", methods=["GET", "HEAD"])
+def health(req: func.HttpRequest) -> func.HttpResponse:
+ """Front Door's health probe target.
+
+ The probe is a HEAD request, so this route has to accept HEAD as well as GET: an origin that
+ answers the probe with 405 is taken out of rotation and its route starts returning 503.
+ """
+ return json_response({"status": "healthy", "origin": ORIGIN_NAME})
+
+
+@app.route(route="whoami", methods=["GET"])
+def whoami(req: func.HttpRequest) -> func.HttpResponse:
+ """What the origin received, including the headers Front Door added."""
+ headers = {
+ name: value for name, value in req.headers.items() if name.lower() in FRONT_DOOR_HEADERS
+ }
+ return json_response(
+ {
+ "origin": ORIGIN_NAME,
+ "path": origin_path(req),
+ "method": req.method,
+ "front_door_headers": headers,
+ }
+ )
+
+
+@app.route(route="status", methods=["GET"])
+def status(req: func.HttpRequest) -> func.HttpResponse:
+ """The target of the sample's second, more specific route."""
+ return json_response({"origin": ORIGIN_NAME, "path": origin_path(req), "status": "ok"})
+
+
+@app.route(route="catalog/{item}", methods=["GET"])
+def catalog(req: func.HttpRequest) -> func.HttpResponse:
+ """A cacheable response: Front Door stores it for as long as this ``Cache-Control`` allows."""
+ wanted = req.route_params.get("item")
+ item = CATALOG.get(wanted)
+ if item is None:
+ return json_response({"error": f"No catalog item {wanted}"}, status_code=404)
+ return json_response(
+ {"origin": ORIGIN_NAME, "path": origin_path(req), "item": item},
+ cache_control="public, max-age=300",
+ )
diff --git a/samples/function-app-front-door/python/function/hello/__init__.py b/samples/function-app-front-door/python/function/hello/__init__.py
deleted file mode 100644
index 35ed81e..0000000
--- a/samples/function-app-front-door/python/function/hello/__init__.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-Minimal HTTP-triggered function without azure.functions dependency.
-Returns plain text so it works in both Azure and LocalStack without needing the
-'azure-functions' package during local build/publish.
-
-Enhancements for demos:
-- Echoes the WEBSITE_HOSTNAME so multi-origin routing can be observed easily.
-- If header 'x-echo' is present in the request, it is echoed back in the body.
-"""
-
-import os
-
-
-def main(req):
- # Try to read the {name} from route params first
- name = None
- try:
- if hasattr(req, 'route_params') and req.route_params:
- name = req.route_params.get('name')
- except Exception:
- name = None
-
- # Fallback to query string (?name=foo)
- if not name:
- try:
- if hasattr(req, 'params') and req.params:
- name = req.params.get('name')
- except Exception:
- name = None
-
- if not name:
- name = 'world'
-
- # Detect which host/app served the request to distinguish origins
- host = os.environ.get('WEBSITE_HOSTNAME') or 'unknown-host'
-
- # Optionally echo a header for debugging
- echoed = None
- try:
- if hasattr(req, 'headers') and req.headers:
- echoed = req.headers.get('x-echo') or req.headers.get('X-Echo')
- except Exception:
- echoed = None
-
- body = f"hello {name} from {host}"
- if echoed:
- body += f" (echoed-header={echoed})"
-
- # Returning a string lets the runtime create a 200 OK text/plain response.
- return body
diff --git a/samples/function-app-front-door/python/function/hello/function.json b/samples/function-app-front-door/python/function/hello/function.json
deleted file mode 100644
index 093698e..0000000
--- a/samples/function-app-front-door/python/function/hello/function.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "scriptFile": "__init__.py",
- "bindings": [
- {
- "authLevel": "Anonymous",
- "type": "httpTrigger",
- "direction": "in",
- "name": "req",
- "methods": [ "get" ],
- "route": "{name?}"
- },
- {
- "type": "http",
- "direction": "out",
- "name": "$return"
- }
- ]
-}
diff --git a/samples/function-app-front-door/python/function/host.json b/samples/function-app-front-door/python/function/host.json
index ee90cba..b7e5ad1 100644
--- a/samples/function-app-front-door/python/function/host.json
+++ b/samples/function-app-front-door/python/function/host.json
@@ -1,8 +1,7 @@
{
"version": "2.0",
- "extensions": {
- "http": {
- "routePrefix": ""
- }
+ "extensionBundle": {
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
+ "version": "[4.*, 5.0.0)"
}
}
diff --git a/samples/function-app-front-door/python/function/requirements.txt b/samples/function-app-front-door/python/function/requirements.txt
index e69de29..75db2c4 100644
--- a/samples/function-app-front-door/python/function/requirements.txt
+++ b/samples/function-app-front-door/python/function/requirements.txt
@@ -0,0 +1 @@
+azure-functions
diff --git a/samples/function-app-front-door/python/images/basic.png b/samples/function-app-front-door/python/images/basic.png
deleted file mode 100644
index 3640d8b..0000000
Binary files a/samples/function-app-front-door/python/images/basic.png and /dev/null differ
diff --git a/samples/function-app-front-door/python/images/disabled_state.png b/samples/function-app-front-door/python/images/disabled_state.png
deleted file mode 100644
index af62d00..0000000
Binary files a/samples/function-app-front-door/python/images/disabled_state.png and /dev/null differ
diff --git a/samples/function-app-front-door/python/images/multi.png b/samples/function-app-front-door/python/images/multi.png
deleted file mode 100644
index fab054f..0000000
Binary files a/samples/function-app-front-door/python/images/multi.png and /dev/null differ
diff --git a/samples/function-app-front-door/python/images/rules.png b/samples/function-app-front-door/python/images/rules.png
deleted file mode 100644
index cc6551f..0000000
Binary files a/samples/function-app-front-door/python/images/rules.png and /dev/null differ
diff --git a/samples/function-app-front-door/python/images/spec.png b/samples/function-app-front-door/python/images/spec.png
deleted file mode 100644
index 466d873..0000000
Binary files a/samples/function-app-front-door/python/images/spec.png and /dev/null differ
diff --git a/samples/function-app-front-door/python/scripts/.last_deploy_all.env b/samples/function-app-front-door/python/scripts/.last_deploy_all.env
deleted file mode 100644
index 69f3cff..0000000
--- a/samples/function-app-front-door/python/scripts/.last_deploy_all.env
+++ /dev/null
@@ -1,10 +0,0 @@
-RESOURCE_GROUP="rg-testafd-30236"
-PROFILE_NAME="afd-testafd-30236"
-EP_BASIC="ep-testafd-basic-30236"
-EP_MULTI="ep-testafd-multi-30236"
-EP_SPEC="ep-testafd-spec-30236"
-EP_RULES="ep-testafd-rules-30236"
-EP_STATE="ep-testafd-state-30236"
-FUNC_MAIN="fa-testafd-30236"
-FUNC_A="fa-testafda-30236"
-FUNC_B="fa-testafdb-30236"
diff --git a/samples/function-app-front-door/python/scripts/README.md b/samples/function-app-front-door/python/scripts/README.md
new file mode 100644
index 0000000..4c4e3af
--- /dev/null
+++ b/samples/function-app-front-door/python/scripts/README.md
@@ -0,0 +1,25 @@
+# Azure CLI Deployment
+
+This directory contains Bash scripts for deploying and validating the sample using the `lstk` CLI. For details about the sample application, see [Front Door and Function Apps](../README.md).
+
+## Prerequisites
+
+- [LocalStack for Azure](https://docs.localstack.cloud/azure/)
+- [Docker](https://docs.docker.com/get-docker/)
+- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
+- [lstk CLI](https://docs.localstack.cloud/aws/developer-tools/running-localstack/lstk/)
+- [jq](https://jqlang.org/) and `zip`
+
+## Scripts
+
+| Script | Purpose |
+|--------|---------|
+| `deploy.sh` | Idempotently provisions two Function Apps and their storage on a shared App Service plan, deploys the same zip package to both, then creates the Front Door profile, endpoint, two origin groups, three origins, the rule set with its three rules, and the two routes. Prints the endpoint URL and the commands to try it. |
+| `validate.sh` | Walks the whole chain (origin health and the probe method, routing, route specificity, origin priority, the three rules, caching and purge, the headers the edge adds, an origin error, the endpoint's enabled state) and exits non-zero on any failure. |
+| `call-front-door.sh` | Quick user-level smoke test: read a catalog item, read it again from the edge cache, follow the rewrite and the redirect, and print what the origin received. |
+| `cleanup.sh` | Deletes the resource group and the local zip artifact. |
+
+## Notes
+
+- The scripts read `az account show --query environmentName` and adjust two things for the emulator: the routes forward to the origins over plain HTTP, and the endpoint is called through its `*.afd.azure.localhost.localstack.cloud:4566` alias rather than its `*.azurefd.net` host name.
+- `deploy.sh` supports both spellings of `az afd rule create`: the flattened arguments of Azure CLI 2.83 and earlier, and the `--conditions`/`--actions` shorthand of the `cdn` extension used from 2.85 on.
diff --git a/samples/function-app-front-door/python/scripts/call-front-door.sh b/samples/function-app-front-door/python/scripts/call-front-door.sh
new file mode 100755
index 0000000..976b71d
--- /dev/null
+++ b/samples/function-app-front-door/python/scripts/call-front-door.sh
@@ -0,0 +1,79 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+PROFILE_NAME="${PREFIX}-catalog-afd-${SUFFIX}"
+ENDPOINT_NAME="${PREFIX}-catalog-${SUFFIX}"
+BODY_FILE='/tmp/front_door_call.json'
+HEADERS_FILE='/tmp/front_door_call_headers.txt'
+
+# Retrieve the Front Door profile
+echo "Retrieving the [$PROFILE_NAME] Front Door profile..."
+PROFILE_ID=$(az afd profile show --profile-name $PROFILE_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+if [[ -n "$PROFILE_ID" ]]; then
+ echo "[$PROFILE_NAME] Front Door profile successfully retrieved"
+else
+ echo "Failed to retrieve the [$PROFILE_NAME] Front Door profile"
+ exit 1
+fi
+
+# Where the endpoint answers: Azure's hostName, or the emulator's local alias (the emulator also
+# claims the *.azurefd.net name, but it only resolves once LocalStack's DNS is in front of the machine)
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ ENDPOINT_URL="http://${ENDPOINT_NAME}.afd.azure.localhost.localstack.cloud:4566"
+else
+ ENDPOINT_HOST_NAME=$(az afd endpoint show --endpoint-name $ENDPOINT_NAME --profile-name $PROFILE_NAME --resource-group $RESOURCE_GROUP_NAME --query hostName --output tsv)
+ ENDPOINT_URL="https://$ENDPOINT_HOST_NAME"
+fi
+
+# Call the endpoint and report what the edge did with the request
+call_endpoint() {
+ local PATH_TO_CALL="$1"
+ STATUS=$(curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" -w "%{http_code}" "$ENDPOINT_URL$PATH_TO_CALL")
+ CACHE=$(grep -i "^x-cache:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+ SERVED_BY=$(grep -i "^x-served-by:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+ LOCATION=$(grep -i "^location:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+}
+
+echo "Calling [$ENDPOINT_URL/catalog/1]..."
+call_endpoint /catalog/1
+
+if [[ "$STATUS" == "200" ]]; then
+ jq . "$BODY_FILE"
+ echo "Cache status: ${CACHE:-(none)}, stamped by the rule set: ${SERVED_BY:-(none)}"
+else
+ echo "[$ENDPOINT_URL/catalog/1] returned [$STATUS]: $(cat "$BODY_FILE")"
+ exit 1
+fi
+
+echo "Calling it again, to be served from the edge cache..."
+call_endpoint /catalog/1
+echo "Cache status: ${CACHE:-(none)}"
+
+echo "Calling [$ENDPOINT_URL/shop/3], which the rules engine rewrites to /catalog/3..."
+call_endpoint /shop/3
+
+if [[ "$STATUS" == "200" ]]; then
+ echo "The origin was asked for $(jq -r '.path' "$BODY_FILE") and answered with $(jq -r '.item.name' "$BODY_FILE")"
+else
+ echo "[$ENDPOINT_URL/shop/3] returned [$STATUS]: $(cat "$BODY_FILE")"
+ exit 1
+fi
+
+echo "Calling [$ENDPOINT_URL/legacy], which the rules engine redirects..."
+call_endpoint /legacy
+echo "Answered [$STATUS] at the edge, pointing at ${LOCATION:-(no Location header)}"
+
+echo "Calling [$ENDPOINT_URL/whoami], which reports what the origin received..."
+call_endpoint /whoami
+
+if [[ "$STATUS" == "200" ]]; then
+ jq . "$BODY_FILE"
+else
+ echo "[$ENDPOINT_URL/whoami] returned [$STATUS]: $(cat "$BODY_FILE")"
+ exit 1
+fi
diff --git a/samples/function-app-front-door/python/scripts/cleanup.sh b/samples/function-app-front-door/python/scripts/cleanup.sh
new file mode 100755
index 0000000..ede7f93
--- /dev/null
+++ b/samples/function-app-front-door/python/scripts/cleanup.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+# =============================================================================
+# Removes everything scripts/deploy.sh created.
+#
+# Deleting the resource group is enough: the Front Door profile, its endpoint,
+# origin groups, origins, routes and rule set, both Function Apps, their storage
+# accounts and the shared App Service plan are all inside it.
+# =============================================================================
+
+PREFIX='local'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "Deleting resource group [$RESOURCE_GROUP_NAME] and everything in it..."
+az group delete \
+ --name "$RESOURCE_GROUP_NAME" \
+ --yes \
+ --only-show-errors 1>/dev/null
+
+if [[ $? -eq 0 ]]; then
+ echo "Resource group [$RESOURCE_GROUP_NAME] deleted."
+else
+ echo "WARNING: could not delete resource group [$RESOURCE_GROUP_NAME] (it may not exist)."
+fi
+
+rm -f "$CURRENT_DIR"/../function/*.zip
+echo "Removed local deployment artifacts."
diff --git a/samples/function-app-front-door/python/scripts/cleanup_all.sh b/samples/function-app-front-door/python/scripts/cleanup_all.sh
deleted file mode 100644
index 8139d13..0000000
--- a/samples/function-app-front-door/python/scripts/cleanup_all.sh
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Unified cleanup script for the consolidated Function App + Azure Front Door samples.
-#
-# This script deletes the resource group created by scripts/deploy_all.sh. It can read
-# the resource group from an env file produced by the deploy script or accept it via flag.
-#
-# Usage examples:
-# # Using the env file written by deploy_all.sh
-# bash ./scripts/cleanup_all.sh --env-file ./scripts/.last_deploy_all.env
-#
-# # Passing the RG directly
-# bash ./scripts/cleanup_all.sh --resource-group rg-funcafdall-12345
-#
-# Requirements: az CLI
-
-RESOURCE_GROUP=""
-ENV_FILE=""
-
-print_usage() {
- cat <&2; print_usage; exit 1;;
- esac
-done
-
-if [[ -n "$ENV_FILE" ]]; then
- if [[ ! -f "$ENV_FILE" ]]; then
- echo "Env file not found: $ENV_FILE" >&2; exit 1
- fi
- # shellcheck disable=SC1090
- source "$ENV_FILE"
-fi
-
-if [[ -z "$RESOURCE_GROUP" ]]; then
- echo "Resource group not specified. Provide -g/--resource-group or --env-file pointing to deploy env." >&2
- exit 1
-fi
-
-az group delete -n "$RESOURCE_GROUP" --yes --no-wait
-echo "Delete requested for resource group '$RESOURCE_GROUP'."
diff --git a/samples/function-app-front-door/python/scripts/deploy.sh b/samples/function-app-front-door/python/scripts/deploy.sh
new file mode 100755
index 0000000..0d419c2
--- /dev/null
+++ b/samples/function-app-front-door/python/scripts/deploy.sh
@@ -0,0 +1,518 @@
+#!/bin/bash
+
+# Variables
+PREFIX='local'
+SUFFIX='test'
+LOCATION='westeurope'
+RESOURCE_GROUP_NAME="${PREFIX}-rg"
+APP_SERVICE_PLAN_NAME="${PREFIX}-catalog-app-service-plan-${SUFFIX}"
+APP_SERVICE_PLAN_SKU='B1'
+RUNTIME='python'
+RUNTIME_VERSION='3.11'
+FUNCTIONS_VERSION='4'
+# The two Function Apps are identical apart from their ORIGIN_NAME setting, which is what makes
+# origin selection and route matching observable in the response body.
+PRIMARY_FUNCTION_APP_NAME="${PREFIX}-catalog-primary-${SUFFIX}"
+SECONDARY_FUNCTION_APP_NAME="${PREFIX}-catalog-secondary-${SUFFIX}"
+# Storage account names are limited to 24 characters, lower case and digits only.
+PRIMARY_STORAGE_ACCOUNT_NAME="${PREFIX}catalogpri${SUFFIX}"
+SECONDARY_STORAGE_ACCOUNT_NAME="${PREFIX}catalogsec${SUFFIX}"
+PROFILE_NAME="${PREFIX}-catalog-afd-${SUFFIX}"
+PROFILE_SKU='Standard_AzureFrontDoor'
+ENDPOINT_NAME="${PREFIX}-catalog-${SUFFIX}"
+PRIMARY_ORIGIN_GROUP='catalog-origin-group'
+STANDBY_ORIGIN_GROUP='status-origin-group'
+PRIMARY_ORIGIN='primary'
+STANDBY_ORIGIN='standby'
+SECONDARY_ORIGIN='secondary'
+CATCH_ALL_ROUTE='catalog-route'
+STATUS_ROUTE='status-route'
+RULE_SET_NAME='catalogrules'
+# The Functions host serves every HTTP trigger under /api, and the route's origin path puts it
+# back on the way to the origin, so clients never see it.
+ORIGIN_PATH='/api'
+PROBE_PATH='/api/health'
+FUNCTION_ZIPFILE='catalog_function.zip'
+CURRENT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Get the current subscription
+SUBSCRIPTION_NAME=$(az account show --query name --output tsv)
+
+# The emulator's Function Apps answer on plain HTTP under their own hostnames, while real Azure
+# serves *.azurewebsites.net over HTTPS only, so the route reaches the origins over a different
+# protocol in each environment. Everything else below is the same on both.
+ENVIRONMENT_NAME=$(az account show --query environmentName --output tsv)
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ FORWARDING_PROTOCOL='HttpOnly'
+ HTTPS_REDIRECT='Disabled'
+else
+ FORWARDING_PROTOCOL='HttpsOnly'
+ HTTPS_REDIRECT='Enabled'
+fi
+
+# Check if the resource group already exists
+echo "Checking if [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription..."
+az group show --name $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$RESOURCE_GROUP_NAME] resource group actually exists in the [$SUBSCRIPTION_NAME] subscription"
+ echo "Creating [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription..."
+
+ az group create --name $RESOURCE_GROUP_NAME --location "$LOCATION" 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$RESOURCE_GROUP_NAME] resource group successfully created in the [$SUBSCRIPTION_NAME] subscription"
+ else
+ echo "Failed to create [$RESOURCE_GROUP_NAME] resource group in the [$SUBSCRIPTION_NAME] subscription"
+ exit 1
+ fi
+else
+ echo "[$RESOURCE_GROUP_NAME] resource group already exists in the [$SUBSCRIPTION_NAME] subscription"
+fi
+
+# Check if the app service plan already exists. Both Function Apps share it, as they would on Azure.
+echo "Checking if [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az appservice plan show --name $APP_SERVICE_PLAN_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "No [$APP_SERVICE_PLAN_NAME] app service plan actually exists in the [$RESOURCE_GROUP_NAME] resource group"
+ echo "Creating [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az appservice plan create \
+ --name $APP_SERVICE_PLAN_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --location "$LOCATION" \
+ --sku $APP_SERVICE_PLAN_SKU \
+ --is-linux 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$APP_SERVICE_PLAN_NAME] app service plan successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$APP_SERVICE_PLAN_NAME] app service plan in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$APP_SERVICE_PLAN_NAME] app service plan already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+# Create the zip package once; both Function Apps run the same code
+cd "$CURRENT_DIR/../function" || exit
+if [ -f "$FUNCTION_ZIPFILE" ]; then
+ rm "$FUNCTION_ZIPFILE"
+fi
+echo "Creating zip package of the function app..."
+zip -r "$FUNCTION_ZIPFILE" function_app.py host.json requirements.txt
+
+# Create a storage account and a Function App, set the origin name it reports, and deploy the code
+create_function_app() {
+ local FUNCTION_APP_NAME=$1
+ local STORAGE_ACCOUNT_NAME=$2
+ local ORIGIN_NAME=$3
+
+ echo "Checking if [$STORAGE_ACCOUNT_NAME] storage account actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+ az storage account show --name "$STORAGE_ACCOUNT_NAME" --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Creating [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az storage account create \
+ --name "$STORAGE_ACCOUNT_NAME" \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --location "$LOCATION" \
+ --sku Standard_LRS 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to create [$STORAGE_ACCOUNT_NAME] storage account in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+ echo "[$STORAGE_ACCOUNT_NAME] storage account successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "[$STORAGE_ACCOUNT_NAME] storage account already exists in the [$RESOURCE_GROUP_NAME] resource group"
+ fi
+
+ echo "Checking if [$FUNCTION_APP_NAME] function app actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+ az functionapp show --name "$FUNCTION_APP_NAME" --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Creating [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az functionapp create \
+ --name "$FUNCTION_APP_NAME" \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --plan $APP_SERVICE_PLAN_NAME \
+ --storage-account "$STORAGE_ACCOUNT_NAME" \
+ --runtime $RUNTIME \
+ --runtime-version $RUNTIME_VERSION \
+ --functions-version $FUNCTIONS_VERSION \
+ --os-type Linux 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to create [$FUNCTION_APP_NAME] function app in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+ echo "[$FUNCTION_APP_NAME] function app successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "[$FUNCTION_APP_NAME] function app already exists in the [$RESOURCE_GROUP_NAME] resource group"
+ fi
+
+ echo "Setting app settings for the [$FUNCTION_APP_NAME] function app..."
+ az functionapp config appsettings set \
+ --name "$FUNCTION_APP_NAME" \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --settings \
+ FUNCTIONS_WORKER_RUNTIME="$RUNTIME" \
+ SCM_DO_BUILD_DURING_DEPLOYMENT='true' \
+ ENABLE_ORYX_BUILD='true' \
+ ORIGIN_NAME="$ORIGIN_NAME" 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to set app settings for the [$FUNCTION_APP_NAME] function app"
+ exit 1
+ fi
+
+ echo "Deploying function app [$FUNCTION_APP_NAME] with zip file [$FUNCTION_ZIPFILE]..."
+ az functionapp deploy \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --name "$FUNCTION_APP_NAME" \
+ --src-path "$FUNCTION_ZIPFILE" \
+ --type zip \
+ --async true 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to deploy function app [$FUNCTION_APP_NAME]"
+ exit 1
+ fi
+ echo "Function app [$FUNCTION_APP_NAME] deployed successfully"
+}
+
+create_function_app "$PRIMARY_FUNCTION_APP_NAME" "$PRIMARY_STORAGE_ACCOUNT_NAME" primary
+create_function_app "$SECONDARY_FUNCTION_APP_NAME" "$SECONDARY_STORAGE_ACCOUNT_NAME" secondary
+rm -f "$FUNCTION_ZIPFILE"
+
+# An origin's host name carries no port, so split the one the emulator reports. On Azure the
+# default host name is a bare name and the ports below are the standard 80 and 443.
+read_origin_address() {
+ local FUNCTION_APP_NAME=$1
+ local DEFAULT_HOST_NAME
+
+ DEFAULT_HOST_NAME=$(az functionapp show --name "$FUNCTION_APP_NAME" --resource-group $RESOURCE_GROUP_NAME --query defaultHostName --output tsv)
+
+ if [[ -z "$DEFAULT_HOST_NAME" ]]; then
+ echo "Failed to read the default host name of the [$FUNCTION_APP_NAME] function app" >&2
+ exit 1
+ fi
+
+ ORIGIN_HOST_NAME="${DEFAULT_HOST_NAME%%:*}"
+ if [[ "$DEFAULT_HOST_NAME" == *:* ]]; then
+ ORIGIN_HTTP_PORT="${DEFAULT_HOST_NAME##*:}"
+ ORIGIN_HTTPS_PORT="${DEFAULT_HOST_NAME##*:}"
+ else
+ ORIGIN_HTTP_PORT=80
+ ORIGIN_HTTPS_PORT=443
+ fi
+ # The origin host header is what the Function App is addressed by, port included: that is the
+ # name it is routed by, and the name it reports as WEBSITE_HOSTNAME.
+ ORIGIN_HOST_HEADER="$DEFAULT_HOST_NAME"
+}
+
+read_origin_address "$PRIMARY_FUNCTION_APP_NAME"
+PRIMARY_HOST_NAME=$ORIGIN_HOST_NAME
+PRIMARY_HOST_HEADER=$ORIGIN_HOST_HEADER
+PRIMARY_HTTP_PORT=$ORIGIN_HTTP_PORT
+PRIMARY_HTTPS_PORT=$ORIGIN_HTTPS_PORT
+
+read_origin_address "$SECONDARY_FUNCTION_APP_NAME"
+SECONDARY_HOST_NAME=$ORIGIN_HOST_NAME
+SECONDARY_HOST_HEADER=$ORIGIN_HOST_HEADER
+SECONDARY_HTTP_PORT=$ORIGIN_HTTP_PORT
+SECONDARY_HTTPS_PORT=$ORIGIN_HTTPS_PORT
+
+echo "Primary origin: $PRIMARY_HOST_HEADER"
+echo "Secondary origin: $SECONDARY_HOST_HEADER"
+
+# Check if the Front Door profile already exists
+echo "Checking if [$PROFILE_NAME] Front Door profile actually exists in the [$RESOURCE_GROUP_NAME] resource group..."
+az afd profile show --profile-name $PROFILE_NAME --resource-group $RESOURCE_GROUP_NAME &>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "Creating [$PROFILE_NAME] Front Door profile in the [$RESOURCE_GROUP_NAME] resource group..."
+
+ az afd profile create \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --sku $PROFILE_SKU 1>/dev/null
+
+ if [[ $? == 0 ]]; then
+ echo "[$PROFILE_NAME] Front Door profile successfully created in the [$RESOURCE_GROUP_NAME] resource group"
+ else
+ echo "Failed to create [$PROFILE_NAME] Front Door profile in the [$RESOURCE_GROUP_NAME] resource group"
+ exit 1
+ fi
+else
+ echo "[$PROFILE_NAME] Front Door profile already exists in the [$RESOURCE_GROUP_NAME] resource group"
+fi
+
+# The endpoint is the address clients call; its host name is assigned by Azure
+echo "Creating the [$ENDPOINT_NAME] endpoint..."
+az afd endpoint create \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --enabled-state Enabled 1>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [$ENDPOINT_NAME] endpoint"
+ exit 1
+fi
+
+# Origin groups carry the health probe and the load-balancing settings. The probe is a HEAD
+# request to a path the Function App answers; an origin that fails it is taken out of rotation.
+create_origin_group() {
+ local ORIGIN_GROUP_NAME=$1
+
+ echo "Creating the [$ORIGIN_GROUP_NAME] origin group..."
+ az afd origin-group create \
+ --origin-group-name "$ORIGIN_GROUP_NAME" \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --probe-request-type HEAD \
+ --probe-protocol Http \
+ --probe-path $PROBE_PATH \
+ --probe-interval-in-seconds 30 \
+ --sample-size 4 \
+ --successful-samples-required 3 \
+ --additional-latency-in-milliseconds 50 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to create the [$ORIGIN_GROUP_NAME] origin group"
+ exit 1
+ fi
+}
+
+create_origin_group $PRIMARY_ORIGIN_GROUP
+create_origin_group $STANDBY_ORIGIN_GROUP
+
+# Priority is a strict tier, not a preference: while a priority-1 origin is healthy, the
+# priority-2 origin receives nothing at all.
+create_origin() {
+ local ORIGIN_GROUP_NAME=$1
+ local ORIGIN_NAME=$2
+ local HOST_NAME=$3
+ local HOST_HEADER=$4
+ local HTTP_PORT=$5
+ local HTTPS_PORT=$6
+ local PRIORITY=$7
+
+ echo "Creating the [$ORIGIN_NAME] origin in the [$ORIGIN_GROUP_NAME] origin group..."
+ az afd origin create \
+ --origin-name "$ORIGIN_NAME" \
+ --origin-group-name "$ORIGIN_GROUP_NAME" \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --host-name "$HOST_NAME" \
+ --origin-host-header "$HOST_HEADER" \
+ --http-port "$HTTP_PORT" \
+ --https-port "$HTTPS_PORT" \
+ --priority "$PRIORITY" \
+ --weight 1000 \
+ --enabled-state Enabled 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to create the [$ORIGIN_NAME] origin"
+ exit 1
+ fi
+}
+
+create_origin $PRIMARY_ORIGIN_GROUP $PRIMARY_ORIGIN "$PRIMARY_HOST_NAME" "$PRIMARY_HOST_HEADER" "$PRIMARY_HTTP_PORT" "$PRIMARY_HTTPS_PORT" 1
+create_origin $PRIMARY_ORIGIN_GROUP $STANDBY_ORIGIN "$SECONDARY_HOST_NAME" "$SECONDARY_HOST_HEADER" "$SECONDARY_HTTP_PORT" "$SECONDARY_HTTPS_PORT" 2
+create_origin $STANDBY_ORIGIN_GROUP $SECONDARY_ORIGIN "$SECONDARY_HOST_NAME" "$SECONDARY_HOST_HEADER" "$SECONDARY_HTTP_PORT" "$SECONDARY_HTTPS_PORT" 1
+
+# The rule set: three rules, each one action, applied to the catch-all route.
+echo "Creating the [$RULE_SET_NAME] rule set..."
+az afd rule-set create \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME 1>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [$RULE_SET_NAME] rule set"
+ exit 1
+fi
+
+# `az afd rule create` comes in two spellings. Up to Azure CLI 2.83 the command is part of the CLI
+# itself and takes one flattened condition and action; from 2.85 it lives in the `cdn` extension,
+# which takes --conditions and --actions in its own shorthand syntax instead. The sample supports
+# both, because which one is installed is not the sample's to decide.
+if az afd rule create --help 2>/dev/null | grep -q -- '--actions'; then
+ AFD_RULE_SYNTAX='extension'
+else
+ AFD_RULE_SYNTAX='cli'
+fi
+echo "Creating the rules ($AFD_RULE_SYNTAX syntax)..."
+
+# Rule 1: stamp every response that came through Front Door. The condition matches the request
+# method, so it applies to all of the sample's traffic.
+if [[ "$AFD_RULE_SYNTAX" == 'extension' ]]; then
+ az afd rule create \
+ --rule-name stampHeader \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 1 \
+ --match-processing-behavior Continue \
+ --conditions "[{request-method:{parameters:{operator:Equal,match-values:[GET],negate-condition:false}}}]" \
+ --actions "[{modify-response-header:{parameters:{header-action:Overwrite,header-name:X-Served-By,value:front-door}}}]" 1>/dev/null
+else
+ az afd rule create \
+ --rule-name stampHeader \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 1 \
+ --match-processing-behavior Continue \
+ --match-variable RequestMethod --operator Equal --match-values GET \
+ --action-name ModifyResponseHeader --header-action Overwrite --header-name X-Served-By --header-value front-door 1>/dev/null
+fi
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [stampHeader] rule"
+ exit 1
+fi
+
+# Rule 2: publish the catalog under a friendlier path. Note the match value: the UrlPath condition
+# sees the path *without* its leading slash, while UrlRewrite's source pattern keeps it.
+if [[ "$AFD_RULE_SYNTAX" == 'extension' ]]; then
+ az afd rule create \
+ --rule-name rewriteShop \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 2 \
+ --match-processing-behavior Continue \
+ --conditions "[{url-path:{parameters:{operator:BeginsWith,match-values:[shop],negate-condition:false}}}]" \
+ --actions "[{url-rewrite:{parameters:{source-pattern:/shop,destination:/catalog,preserve-unmatched-path:true}}}]" 1>/dev/null
+else
+ az afd rule create \
+ --rule-name rewriteShop \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 2 \
+ --match-processing-behavior Continue \
+ --match-variable UrlPath --operator BeginsWith --match-values shop \
+ --action-name UrlRewrite --source-pattern /shop --destination /catalog --preserve-unmatched-path true 1>/dev/null
+fi
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [rewriteShop] rule"
+ exit 1
+fi
+
+# Rule 3: retire an old path at the edge. A redirect is terminal, so the origin is never called.
+if [[ "$AFD_RULE_SYNTAX" == 'extension' ]]; then
+ az afd rule create \
+ --rule-name redirectLegacy \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 3 \
+ --conditions "[{url-path:{parameters:{operator:BeginsWith,match-values:[legacy],negate-condition:false}}}]" \
+ --actions "[{url-redirect:{parameters:{redirect-type:Found,destination-protocol:MatchRequest,custom-path:/status}}}]" 1>/dev/null
+else
+ az afd rule create \
+ --rule-name redirectLegacy \
+ --rule-set-name $RULE_SET_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --order 3 \
+ --match-variable UrlPath --operator BeginsWith --match-values legacy \
+ --action-name UrlRedirect --redirect-type Found --redirect-protocol MatchRequest --custom-path /status 1>/dev/null
+fi
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [redirectLegacy] rule"
+ exit 1
+fi
+
+# The two routes. Caching and rule sets are spelled differently by the two CLI generations too:
+# --enable-caching / --rule-sets in the CLI, --cache-configuration / --formatted-rule-sets in the
+# extension, which takes resource IDs rather than names.
+RULE_SET_ID=$(az afd rule-set show --rule-set-name $RULE_SET_NAME --profile-name $PROFILE_NAME --resource-group $RESOURCE_GROUP_NAME --query id --output tsv)
+
+echo "Creating the [$CATCH_ALL_ROUTE] route..."
+if [[ "$AFD_RULE_SYNTAX" == 'extension' ]]; then
+ az afd route create \
+ --route-name $CATCH_ALL_ROUTE \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --origin-group $PRIMARY_ORIGIN_GROUP \
+ --origin-path $ORIGIN_PATH \
+ --patterns-to-match '/*' \
+ --supported-protocols Http Https \
+ --link-to-default-domain Enabled \
+ --https-redirect $HTTPS_REDIRECT \
+ --forwarding-protocol $FORWARDING_PROTOCOL \
+ --formatted-rule-sets "[{id:'$RULE_SET_ID'}]" \
+ --cache-configuration "{query-string-caching-behavior:IgnoreQueryString}" 1>/dev/null
+else
+ az afd route create \
+ --route-name $CATCH_ALL_ROUTE \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --origin-group $PRIMARY_ORIGIN_GROUP \
+ --origin-path $ORIGIN_PATH \
+ --patterns-to-match '/*' \
+ --supported-protocols Http Https \
+ --link-to-default-domain Enabled \
+ --https-redirect $HTTPS_REDIRECT \
+ --forwarding-protocol $FORWARDING_PROTOCOL \
+ --rule-sets $RULE_SET_NAME \
+ --enable-caching true \
+ --query-string-caching-behavior IgnoreQueryString 1>/dev/null
+fi
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [$CATCH_ALL_ROUTE] route"
+ exit 1
+fi
+
+# A more specific pattern wins over the catch-all, whatever order the routes were created in.
+# This one has no rule set and no caching, which is how the two routes tell themselves apart.
+echo "Creating the [$STATUS_ROUTE] route..."
+az afd route create \
+ --route-name $STATUS_ROUTE \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --origin-group $STANDBY_ORIGIN_GROUP \
+ --origin-path $ORIGIN_PATH \
+ --patterns-to-match '/status' \
+ --supported-protocols Http Https \
+ --link-to-default-domain Enabled \
+ --https-redirect $HTTPS_REDIRECT \
+ --forwarding-protocol $FORWARDING_PROTOCOL 1>/dev/null
+
+if [[ $? != 0 ]]; then
+ echo "Failed to create the [$STATUS_ROUTE] route"
+ exit 1
+fi
+
+# Where the endpoint answers. The emulator also reports Azure's *.azurefd.net address in hostName,
+# but that name only resolves once LocalStack's DNS is in front of the machine, so the local alias
+# is printed instead.
+if [[ "$ENVIRONMENT_NAME" == "LocalStack" ]]; then
+ ENDPOINT_URL="http://${ENDPOINT_NAME}.afd.azure.localhost.localstack.cloud:4566"
+else
+ ENDPOINT_HOST_NAME=$(az afd endpoint show --endpoint-name $ENDPOINT_NAME --profile-name $PROFILE_NAME --resource-group $RESOURCE_GROUP_NAME --query hostName --output tsv)
+ ENDPOINT_URL="https://$ENDPOINT_HOST_NAME"
+fi
+
+echo "Deployment completed. The catalog is published at: $ENDPOINT_URL"
+echo "Try it with:"
+echo " curl -i $ENDPOINT_URL/catalog/1 # cacheable, served by the primary origin"
+echo " curl -i $ENDPOINT_URL/shop/1 # rewritten to /catalog/1 by the rules engine"
+echo " curl -i $ENDPOINT_URL/legacy # redirected to /status by the rules engine"
+echo " curl -i $ENDPOINT_URL/status # the more specific route, served by the secondary origin"
+echo " curl -s $ENDPOINT_URL/whoami | jq # what the origin received"
diff --git a/samples/function-app-front-door/python/scripts/deploy_all.sh b/samples/function-app-front-door/python/scripts/deploy_all.sh
deleted file mode 100644
index b276402..0000000
--- a/samples/function-app-front-door/python/scripts/deploy_all.sh
+++ /dev/null
@@ -1,409 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Unified deployment script for the Function App + Azure Front Door samples.
-#
-# This script provisions everything needed to exercise the following Azure Front Door data plane behaviors:
-# 1) Basic single-origin routing (ep-basic)
-# 2) Multiple origins with priority/weight selection (ep-multi)
-# 3) Route specificity/precedence (ep-spec)
-# 4) Rules Engine demo via Rule Set + Rule (ep-rules)
-# 5) Endpoint enabled/disabled state toggle (ep-state)
-#
-# By default, all scenarios are deployed. You can selectively skip scenarios via flags.
-#
-# Requirements
-# - az CLI
-# - bash, zip
-# - Optional: Azure Functions Core Tools (‘func’)
-#
-# Examples
-# # Real Azure (eastus by default)
-# bash ./scripts/deploy_all.sh --name-prefix demo
-#
-
-# -------------------------------
-# Defaults (overridable via flags)
-# -------------------------------
-NAME_PREFIX="funcafdall"
-LOCATION="eastus"
-RESOURCE_GROUP=""
-PYTHON_VERSION="3.11"
-
-# Scenario toggles
-DO_BASIC="true"
-DO_MULTI="true"
-DO_SPEC="true"
-DO_RULES="true"
-DO_STATE="true"
-
-print_usage() {
- cat <&2; print_usage; exit 1;;
- esac
-done
-
-# -------------------------------
-# Paths and assets
-# -------------------------------
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
-FUNCTION_SRC="$ROOT_DIR/function"
-ZIP_MAIN="$ROOT_DIR/app_main.zip"
-ZIP_A="$ROOT_DIR/app_A.zip"
-ZIP_B="$ROOT_DIR/app_B.zip"
-ENV_OUT="$SCRIPT_DIR/.last_deploy_all.env"
-
-# -------------------------------
-# Name generation
-# -------------------------------
-prefix=$(echo "$NAME_PREFIX" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9')
-[[ -z "$prefix" ]] && prefix="demo"
-suffix=$(printf "%05d" $(( (RANDOM % 100000) )))
-
-[[ -z "$RESOURCE_GROUP" ]] && RESOURCE_GROUP="rg-$prefix-$suffix"
-
-# Storage + Function Apps
-storageMain="st${prefix}${suffix}"; storageMain="${storageMain:0:24}"
-funcMain="fa-$prefix-$suffix"
-
-storageA="st${prefix}a${suffix}"; storageA="${storageA:0:24}"
-storageB="st${prefix}b${suffix}"; storageB="${storageB:0:24}"
-funcA="fa-${prefix}a-$suffix"
-funcB="fa-${prefix}b-$suffix"
-
-# AFD profile and endpoints (one profile, multiple endpoints)
-profileName="afd-$prefix-$suffix"
-epBasic="ep-${prefix}-basic-$suffix"
-epMulti="ep-${prefix}-multi-$suffix"
-epSpec="ep-${prefix}-spec-$suffix"
-epRules="ep-${prefix}-rules-$suffix"
-epState="ep-${prefix}-state-$suffix"
-
-# Origin groups and origins
-ogBasic="og-${prefix}-basic"
-ogSpec="og-${prefix}-spec"
-ogRules="og-${prefix}-rules"
-ogMulti="og-${prefix}-multi"
-
-orMainBasic="or-${prefix}-main-basic"
-orMainSpec="or-${prefix}-main-spec"
-orMainRules="or-${prefix}-main-rules"
-orA="or-${prefix}-a"
-orB="or-${prefix}-b"
-
-# Routes (one per endpoint unless scenario needs multiple)
-rtBasic="rt-${prefix}-basic"
-rtMultiCatchAll="rt-${prefix}-multi-all"
-rtSpecAll="rt-${prefix}-spec-all"
-rtSpecJohn="rt-${prefix}-spec-john"
-rtRules="rt-${prefix}-rules"
-rtState="rt-${prefix}-state"
-
-# Rule Set and Rule names for AFD Rules Engine
-# Constraints: must start with a letter and contain only letters and digits (no hyphens/underscores).
-# We derive alphanumeric names from the sanitized prefix+suffix used elsewhere.
-ruleSetName="rs${prefix}${suffix}"
-ruleName="ruleAddHeader"
-
-# -------------------------------
-# Cleanup on exit
-# -------------------------------
-finish() {
- set +e
- [[ -f "$ZIP_MAIN" ]] && rm -f "$ZIP_MAIN"
- [[ -f "$ZIP_A" ]] && rm -f "$ZIP_A"
- [[ -f "$ZIP_B" ]] && rm -f "$ZIP_B"
- set -e
-}
-trap finish EXIT
-
-echo "Resource Group: $RESOURCE_GROUP"
-
-# -------------------------------
-# Resource Group
-# -------------------------------
-az group create -n "$RESOURCE_GROUP" -l "$LOCATION" -o none
-
-# -------------------------------
-# Function App(s): create + configure
-# -------------------------------
-create_function_app() {
- local funcName="$1"; local storageName="$2"
- az storage account create -g "$RESOURCE_GROUP" -n "$storageName" -l "$LOCATION" --sku Standard_LRS --kind StorageV2 -o none
- az functionapp create -g "$RESOURCE_GROUP" -n "$funcName" \
- --consumption-plan-location "$LOCATION" \
- --runtime python --runtime-version "$PYTHON_VERSION" \
- --functions-version 4 --os-type Linux \
- --storage-account "$storageName" --disable-app-insights -o none
- az functionapp config appsettings set -g "$RESOURCE_GROUP" -n "$funcName" \
- --settings WEBSITE_RUN_FROM_PACKAGE=1 FUNCTIONS_WORKER_RUNTIME=python SCM_DO_BUILD_DURING_DEPLOYMENT=false -o none
-}
-
-publish_function_code() {
- local funcName="$1"; local zipPath="$2"
- rm -f "$zipPath"; ( cd "$FUNCTION_SRC" && zip -rq "$zipPath" . )
- az functionapp deployment source config-zip -g "$RESOURCE_GROUP" -n "$funcName" --src "$zipPath"
-}
-
-if [[ "$DO_BASIC" == "true" || "$DO_SPEC" == "true" || "$DO_RULES" == "true" || "$DO_STATE" == "true" ]]; then
- create_function_app "$funcMain" "$storageMain"
- publish_function_code "$funcMain" "$ZIP_MAIN"
-fi
-
-if [[ "$DO_MULTI" == "true" ]]; then
- create_function_app "$funcA" "$storageA"
- create_function_app "$funcB" "$storageB"
- publish_function_code "$funcA" "$ZIP_A"
- publish_function_code "$funcB" "$ZIP_B"
-fi
-
-# Resolve hostnames
-funcMainHost=""; funcAHost=""; funcBHost=""
-if [[ "$DO_BASIC" == "true" || "$DO_SPEC" == "true" || "$DO_RULES" == "true" || "$DO_STATE" == "true" ]]; then
- funcMainHost=$(az functionapp show -g "$RESOURCE_GROUP" -n "$funcMain" --query defaultHostName -o tsv)
-fi
-if [[ "$DO_MULTI" == "true" ]]; then
- funcAHost=$(az functionapp show -g "$RESOURCE_GROUP" -n "$funcA" --query defaultHostName -o tsv)
- funcBHost=$(az functionapp show -g "$RESOURCE_GROUP" -n "$funcB" --query defaultHostName -o tsv)
-fi
-
-# -------------------------------
-# Azure Front Door: profile
-# -------------------------------
-az afd profile create -g "$RESOURCE_GROUP" --profile-name "$profileName" --sku Standard_AzureFrontDoor -o none
-
-# Helper to create endpoint, origin group, origin, and route
-create_endpoint_single_origin() {
- local endpointName="$1"; local originGroupName="$2"; local originName="$3"; local routeName="$4"; local funcHost="$5"; local patterns="$6"
- az afd endpoint create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$endpointName" --enabled-state Enabled -o none
- az afd origin-group create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$originGroupName" \
- --probe-request-type GET --probe-protocol Http --probe-interval-in-seconds 120 --probe-path / --sample-size 4 --successful-samples-required 3 --additional-latency-in-milliseconds 0 -o none
- az afd origin create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$originGroupName" --origin-name "$originName" \
- --host-name "$funcHost" --origin-host-header "$funcHost" --http-port 80 --https-port 443 -o none
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$endpointName" --route-name "$routeName" \
- --origin-group "$originGroupName" --patterns-to-match "$patterns" --https-redirect Enabled --supported-protocols Http Https --link-to-default-domain Enabled --forwarding-protocol MatchRequest -o none
-}
-
-create_endpoint_multi_origin() {
- local endpointName="$1"; local originGroupName="$2"; local originAName="$3"; local originBName="$4"; local routeName="$5";
- local hostA="$6"; local hostB="$7"; local prioA="$8"; local prioB="$9"; local weightA="${10}"; local weightB="${11}"
- az afd endpoint create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$endpointName" --enabled-state Enabled -o none
- az afd origin-group create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$originGroupName" \
- --probe-request-type HEAD --probe-protocol Http --probe-interval-in-seconds 120 --probe-path / --sample-size 4 --successful-samples-required 3 --additional-latency-in-milliseconds 0 -o none
- az afd origin create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$originGroupName" --origin-name "$originAName" \
- --host-name "$hostA" --origin-host-header "$hostA" --http-port 80 --https-port 443 --priority "$prioA" --weight "$weightA" -o none
- az afd origin create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$originGroupName" --origin-name "$originBName" \
- --host-name "$hostB" --origin-host-header "$hostB" --http-port 80 --https-port 443 --priority "$prioB" --weight "$weightB" -o none
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$endpointName" --route-name "$routeName" \
- --origin-group "$originGroupName" --patterns-to-match '/*' --https-redirect Enabled --supported-protocols Http Https --link-to-default-domain Enabled --forwarding-protocol MatchRequest -o none
-}
-
-# Basic single-origin
-if [[ "$DO_BASIC" == "true" ]]; then
- create_endpoint_single_origin "$epBasic" "$ogBasic" "$orMainBasic" "$rtBasic" "$funcMainHost" '/*'
-fi
-
-# Multi origins
-if [[ "$DO_MULTI" == "true" ]]; then
- create_endpoint_multi_origin "$epMulti" "$ogMulti" "$orA" "$orB" "$rtMultiCatchAll" "$funcAHost" "$funcBHost" 1 2 75 25
-fi
-
-# Route specificity: create two routes on the same endpoint
-if [[ "$DO_SPEC" == "true" ]]; then
- az afd endpoint create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epSpec" --enabled-state Enabled -o none
- az afd origin-group create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$ogSpec" \
- --probe-request-type GET --probe-protocol Http --probe-interval-in-seconds 120 --probe-path / --sample-size 4 --successful-samples-required 3 --additional-latency-in-milliseconds 0 -o none
- az afd origin create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$ogSpec" --origin-name "$orMainSpec" \
- --host-name "$funcMainHost" --origin-host-header "$funcMainHost" --http-port 80 --https-port 443 -o none
- # Catch-all
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epSpec" --route-name "$rtSpecAll" \
- --origin-group "$ogSpec" --patterns-to-match '/*' --https-redirect Enabled --supported-protocols Http Https --link-to-default-domain Enabled --forwarding-protocol MatchRequest -o none
- # Specific '/john'
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epSpec" --route-name "$rtSpecJohn" \
- --origin-group "$ogSpec" --patterns-to-match '/john' --https-redirect Enabled --supported-protocols Http Https --link-to-default-domain Enabled --forwarding-protocol MatchRequest -o none
-fi
-
-# Rules engine demo: create rule set with a rule that adds a response header; attach to route
-if [[ "$DO_RULES" == "true" ]]; then
- az afd endpoint create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epRules" --enabled-state Enabled -o none
- az afd origin-group create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$ogRules" \
- --probe-request-type GET --probe-protocol Http --probe-interval-in-seconds 120 --probe-path / --sample-size 4 --successful-samples-required 3 --additional-latency-in-milliseconds 0 -o none
- az afd origin create -g "$RESOURCE_GROUP" --profile-name "$profileName" --origin-group-name "$ogRules" --origin-name "$orMainRules" \
- --host-name "$funcMainHost" --origin-host-header "$funcMainHost" --http-port 80 --https-port 443 -o none
-
- # Create a Rule Set and a Rule. If the Azure CLI extension/command group is unavailable, skip gracefully.
- set +e
- az afd rule-set create -g "$RESOURCE_GROUP" --profile-name "$profileName" --rule-set-name "$ruleSetName" -o none
- RS_STATUS=$?
- set -e
- if [[ $RS_STATUS -eq 0 ]]; then
- # Rules use the shorthand syntax of the `cdn` CLI extension (>= 1.0.0b1), where
- # conditions/actions are passed as structured lists instead of flattened arguments.
- # Add Rule 1: ModifyResponseHeader when RequestMethod == GET
- set +e
- az afd rule create \
- -g "$RESOURCE_GROUP" \
- --profile-name "$profileName" \
- --rule-set-name "$ruleSetName" \
- --rule-name "$ruleName" \
- --order 1 \
- --match-processing-behavior Continue \
- --conditions "[{request-method:{parameters:{operator:Equal,match-values:[GET],negate-condition:false}}}]" \
- --actions "[{modify-response-header:{parameters:{header-action:Overwrite,header-name:X-CDN,value:MSFT}}}]" -o none
- RULE_STATUS=$?
- set -e
- # Add Rule 2: UrlRewrite when UrlPath begins with /api -> /
- set +e
- az afd rule create \
- -g "$RESOURCE_GROUP" \
- --profile-name "$profileName" \
- --rule-set-name "$ruleSetName" \
- --rule-name rule2 \
- --order 2 \
- --match-processing-behavior Continue \
- --conditions "[{url-path:{parameters:{operator:BeginsWith,match-values:[/api],negate-condition:false}}}]" \
- --actions "[{url-rewrite:{parameters:{source-pattern:/api,destination:/}}}]" -o none
- set -e
- # Add Rule 3: UrlRedirect when UrlPath begins with /old -> /new (302 Found)
- set +e
- az afd rule create \
- -g "$RESOURCE_GROUP" \
- --profile-name "$profileName" \
- --rule-set-name "$ruleSetName" \
- --rule-name rule3 \
- --order 3 \
- --conditions "[{url-path:{parameters:{operator:BeginsWith,match-values:[/old],negate-condition:false}}}]" \
- --actions "[{url-redirect:{parameters:{redirect-type:Found,custom-path:/new}}}]" -o none
- set -e
- else
- echo "Note: 'az afd rule-set' command group not available; skipping rule creation."
- RULE_STATUS=1
- fi
-
- # Create a route and attach the rule set if created. The cdn CLI extension replaced
- # `--rule-sets ` with `--formatted-rule-sets`, which takes resource-id references.
- if [[ $RS_STATUS -eq 0 ]]; then
- RULE_SET_ID=$(az afd rule-set show -g "$RESOURCE_GROUP" --profile-name "$profileName" --rule-set-name "$ruleSetName" --query id -o tsv)
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epRules" --route-name "rt-${prefix}-rules" \
- --origin-group "$ogRules" --patterns-to-match '/*' --https-redirect Enabled --supported-protocols Http Https \
- --link-to-default-domain Enabled --forwarding-protocol MatchRequest --formatted-rule-sets "[{id:'$RULE_SET_ID'}]" -o none
- else
- az afd route create -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$epRules" --route-name "rt-${prefix}-rules" \
- --origin-group "$ogRules" --patterns-to-match '/*' --https-redirect Enabled --supported-protocols Http Https \
- --link-to-default-domain Enabled --forwarding-protocol MatchRequest -o none
- fi
-fi
-
-# Endpoint enabled/disabled scenario: provision an endpoint we can toggle
-if [[ "$DO_STATE" == "true" ]]; then
- create_endpoint_single_origin "$epState" "og-${prefix}-state" "or-${prefix}-state" "rt-${prefix}-state" "$funcMainHost" '/*'
-fi
-
-# -------------------------------
-# Resolve hostnames for output
-# -------------------------------
-resolve_ep_host() { az afd endpoint show -g "$RESOURCE_GROUP" --profile-name "$profileName" --endpoint-name "$1" --query hostName -o tsv || true; }
-
-hostBasic=""; hostMulti=""; hostSpec=""; hostRules=""; hostState=""
-[[ "$DO_BASIC" == "true" ]] && hostBasic=$(resolve_ep_host "$epBasic")
-[[ "$DO_MULTI" == "true" ]] && hostMulti=$(resolve_ep_host "$epMulti")
-[[ "$DO_SPEC" == "true" ]] && hostSpec=$(resolve_ep_host "$epSpec")
-[[ "$DO_RULES" == "true" ]] && hostRules=$(resolve_ep_host "$epRules")
-[[ "$DO_STATE" == "true" ]] && hostState=$(resolve_ep_host "$epState")
-
-# Detect LocalStack environment for local URLs
-IS_LOCALSTACK="false"
-ENVIRONMENT=$(az account show --query environmentName --output tsv 2>/dev/null || true)
-if [[ "$ENVIRONMENT" == "LocalStack" ]]; then
- IS_LOCALSTACK="true"
- epBasicLocal="${epBasic}.afd.localhost.localstack.cloud:4566"
- epMultiLocal="${epMulti}.afd.localhost.localstack.cloud:4566"
- epSpecLocal="${epSpec}.afd.localhost.localstack.cloud:4566"
- epRulesLocal="${epRules}.afd.localhost.localstack.cloud:4566"
- epStateLocal="${epState}.afd.localhost.localstack.cloud:4566"
-fi
-
-# -------------------------------
-# Persist environment for cleanup
-# -------------------------------
-cat > "$ENV_OUT" </dev/null)
+ORIGIN=$(jq -r '.origin' "$BODY_FILE" 2>/dev/null)
+RECEIVED_PATH=$(jq -r '.path' "$BODY_FILE" 2>/dev/null)
+if [[ "$SKU" == "AFD-001" ]]; then
+ echo "The catch-all route reached the catalog"
+else
+ echo "Expected catalog item AFD-001 through the endpoint (got sku=$SKU)"
+ FAILED=1
+fi
+if [[ "$ORIGIN" == "primary" ]]; then
+ echo "The priority-1 origin answered"
+else
+ echo "Expected the priority-1 origin to answer (got: $ORIGIN)"
+ FAILED=1
+fi
+# The route's origin path puts the Functions route prefix back on, so the client never sends it.
+if [[ "$RECEIVED_PATH" == "/api/catalog/1" ]]; then
+ echo "The route's origin path prefixed the request to the origin: $RECEIVED_PATH"
+else
+ echo "Expected the origin to be asked for /api/catalog/1 (got: $RECEIVED_PATH)"
+ FAILED=1
+fi
+
+# 3. The rules engine stamped the response on its way out.
+SERVED_BY=$(grep -i "^x-served-by:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+echo "Response header added by the rule set: ${SERVED_BY:-(none)}"
+if [[ "$SERVED_BY" == "front-door" ]]; then
+ echo "The ModifyResponseHeader rule ran"
+else
+ echo "Expected X-Served-By: front-door from the rule set"
+ FAILED=1
+fi
+
+# Every response carries Front Door's own reference id, which support asks for.
+if grep -qi "^x-azure-ref:" "$HEADERS_FILE"; then
+ echo "The response carries an X-Azure-Ref reference id"
+else
+ echo "Expected an X-Azure-Ref header on the response"
+ FAILED=1
+fi
+
+# 4. A more specific route wins over the catch-all, and sends the request to a different origin
+# group. Both routes are linked to the same endpoint.
+echo "Calling [$ENDPOINT_URL/status]..."
+STATUS_CODE=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" "$ENDPOINT_URL/status")
+STATUS_ORIGIN=$(jq -r '.origin' "$BODY_FILE" 2>/dev/null)
+echo "HTTP $STATUS_CODE: $(cat "$BODY_FILE" | tr -d '\n')"
+if [[ "$STATUS_CODE" == "200" && "$STATUS_ORIGIN" == "secondary" ]]; then
+ echo "/status matched the specific route and went to the other origin group"
+else
+ echo "Expected /status to be served by the secondary origin (got HTTP $STATUS_CODE, origin=$STATUS_ORIGIN)"
+ FAILED=1
+fi
+
+# 5. The UrlRewrite rule: the client asks for /shop/2, the origin is asked for /api/catalog/2.
+echo "Calling [$ENDPOINT_URL/shop/2]..."
+SHOP_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" "$ENDPOINT_URL/shop/2")
+SHOP_PATH=$(jq -r '.path' "$BODY_FILE" 2>/dev/null)
+SHOP_SKU=$(jq -r '.item.sku' "$BODY_FILE" 2>/dev/null)
+echo "HTTP $SHOP_STATUS: origin path $SHOP_PATH, sku $SHOP_SKU"
+if [[ "$SHOP_STATUS" == "200" && "$SHOP_PATH" == "/api/catalog/2" && "$SHOP_SKU" == "AFD-002" ]]; then
+ echo "The UrlRewrite rule rewrote /shop to /catalog before the origin call"
+else
+ echo "Expected /shop/2 to reach the origin as /api/catalog/2 with sku AFD-002"
+ FAILED=1
+fi
+
+# 6. The UrlRedirect rule answers at the edge: the origin is never called.
+echo "Calling [$ENDPOINT_URL/legacy]..."
+REDIRECT_STATUS=$(curl -s -m 20 -o /dev/null -D "$HEADERS_FILE" -w "%{http_code}" "$ENDPOINT_URL/legacy")
+LOCATION=$(grep -i "^location:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+echo "HTTP $REDIRECT_STATUS -> ${LOCATION:-(no Location header)}"
+if [[ "$REDIRECT_STATUS" == "302" && "$LOCATION" == */status ]]; then
+ echo "The UrlRedirect rule answered at the edge"
+else
+ echo "Expected a 302 to /status from the rule set"
+ FAILED=1
+fi
+
+# 7. Caching. The catalog response carries a Cache-Control the edge can honour, so the second
+# request is served from the cache. Azure reports this as TCP_HIT and the emulator as HIT, so the
+# check reads the word rather than the whole value.
+#
+# The first request has to start from an empty cache to mean anything, and an earlier run of this
+# script against the same endpoint would have left entries behind, so purge before measuring.
+purge_catalog() {
+ az afd endpoint purge \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --content-paths '/catalog/*' 1>/dev/null
+
+ if [[ $? != 0 ]]; then
+ echo "Failed to purge the endpoint"
+ FAILED=1
+ fi
+}
+
+echo "Purging /catalog/* so the cache starts empty..."
+purge_catalog
+
+echo "Requesting [$ENDPOINT_URL/catalog/3] twice..."
+curl -s -m 20 -o /dev/null -D "$HEADERS_FILE" "$ENDPOINT_URL/catalog/3"
+FIRST_CACHE=$(grep -i "^x-cache:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+if echo "$FIRST_CACHE" | grep -qi "hit"; then
+ echo "Expected the first request after a purge to reach the origin (got: $FIRST_CACHE)"
+ FAILED=1
+fi
+curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" "$ENDPOINT_URL/catalog/3"
+SECOND_CACHE=$(grep -i "^x-cache:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+CACHE_AGE=$(grep -i "^age:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+echo "First request: X-Cache: ${FIRST_CACHE:-(none)}; second request: X-Cache: ${SECOND_CACHE:-(none)} Age: ${CACHE_AGE:-(none)}"
+if echo "$SECOND_CACHE" | grep -qi "hit"; then
+ echo "The second request was served from the edge cache"
+else
+ echo "Expected the second request to be a cache hit (got: ${SECOND_CACHE:-none})"
+ FAILED=1
+fi
+
+# A purge empties the cache for the paths it names, so the next request goes to the origin again.
+echo "Purging /catalog/* from the endpoint..."
+purge_catalog
+
+PURGED_CACHE=''
+for i in $(seq 1 $PROPAGATION_ATTEMPTS); do
+ curl -s -m 20 -o /dev/null -D "$HEADERS_FILE" "$ENDPOINT_URL/catalog/3"
+ PURGED_CACHE=$(grep -i "^x-cache:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+ echo "$PURGED_CACHE" | grep -qi "hit" || break
+ sleep 5
+done
+echo "After the purge: X-Cache: ${PURGED_CACHE:-(none)}"
+if echo "$PURGED_CACHE" | grep -qi "hit"; then
+ echo "Expected the purged path to be fetched from the origin again"
+ FAILED=1
+else
+ echo "The purged path was fetched from the origin again"
+fi
+
+# A response the origin marks no-store is never cached, however often it is asked for.
+curl -s -m 20 -o /dev/null -D "$HEADERS_FILE" "$ENDPOINT_URL/whoami"
+curl -s -m 20 -o "$BODY_FILE" -D "$HEADERS_FILE" "$ENDPOINT_URL/whoami"
+NOSTORE_CACHE=$(grep -i "^x-cache:" "$HEADERS_FILE" | tr -d '\r' | awk '{print $2}')
+echo "A no-store response: X-Cache: ${NOSTORE_CACHE:-(none)}"
+if echo "$NOSTORE_CACHE" | grep -qi "hit"; then
+ echo "A response the origin marked no-store was served from the cache"
+ FAILED=1
+else
+ echo "The no-store response was not cached"
+fi
+
+# 8. What Front Door tells the origin about the client and about itself.
+echo "Reading [$ENDPOINT_URL/whoami]..."
+jq -r '.front_door_headers' "$BODY_FILE" 2>/dev/null
+FORWARDED_HOST=$(jq -r '.front_door_headers["x-forwarded-host"] // ""' "$BODY_FILE" 2>/dev/null)
+CLIENT_IP=$(jq -r '.front_door_headers["x-azure-clientip"] // ""' "$BODY_FILE" 2>/dev/null)
+FDID=$(jq -r '.front_door_headers["x-azure-fdid"] // ""' "$BODY_FILE" 2>/dev/null)
+if [[ -n "$FORWARDED_HOST" && "$ENDPOINT_URL" == *"$FORWARDED_HOST"* ]]; then
+ echo "The origin was told which host the client asked for: $FORWARDED_HOST"
+else
+ echo "Expected X-Forwarded-Host to name the endpoint (got: ${FORWARDED_HOST:-none})"
+ FAILED=1
+fi
+if [[ -n "$CLIENT_IP" ]]; then
+ echo "The origin was told the client's address: $CLIENT_IP"
+else
+ echo "Expected an X-Azure-ClientIP header at the origin"
+ FAILED=1
+fi
+# The profile's own id, which an origin uses to refuse traffic that did not come through it.
+if [[ -n "$FDID" ]]; then
+ echo "The origin was told which Front Door profile called it: $FDID"
+else
+ echo "Expected an X-Azure-FDID header at the origin"
+ FAILED=1
+fi
+
+# 9. Priority is a strict tier: while the priority-1 origin is healthy the standby gets nothing.
+# /whoami is not cached, so each of these requests reaches an origin.
+echo "Sending ten requests to check origin selection..."
+STANDBY_ANSWERS=0
+for i in $(seq 1 10); do
+ WHO=$(curl -s -m 20 "$ENDPOINT_URL/whoami" | jq -r '.origin' 2>/dev/null)
+ [[ "$WHO" == "secondary" ]] && STANDBY_ANSWERS=$((STANDBY_ANSWERS + 1))
+done
+if [[ $STANDBY_ANSWERS == 0 ]]; then
+ echo "All ten requests were answered by the priority-1 origin"
+else
+ echo "The priority-2 standby answered $STANDBY_ANSWERS of ten requests while the primary was healthy"
+ FAILED=1
+fi
+
+# 10. The origin's own error passes through the edge untouched.
+MISSING_STATUS=$(curl -s -m 20 -o "$BODY_FILE" -w "%{http_code}" "$ENDPOINT_URL/catalog/99")
+echo "HTTP $MISSING_STATUS: $(cat "$BODY_FILE" | tr -d '\n')"
+if [[ "$MISSING_STATUS" == "404" ]] && grep -q "No catalog item 99" "$BODY_FILE"; then
+ echo "The origin's 404 passes through Front Door"
+else
+ echo "Expected the origin's 404 for a missing catalog item (got HTTP $MISSING_STATUS)"
+ FAILED=1
+fi
+
+# 11. A disabled endpoint stops serving, and serves again once it is enabled. This check runs last
+# because it takes the endpoint out of service while it runs.
+echo "Disabling the [$ENDPOINT_NAME] endpoint..."
+az afd endpoint update \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --enabled-state Disabled 1>/dev/null
+
+DISABLED_STATUS=''
+for i in $(seq 1 $PROPAGATION_ATTEMPTS); do
+ DISABLED_STATUS=$(curl -s -m 20 -o /dev/null -w "%{http_code}" "$ENDPOINT_URL/status")
+ [[ "$DISABLED_STATUS" != "200" ]] && break
+ sleep 5
+done
+echo "A disabled endpoint answers: HTTP $DISABLED_STATUS"
+if [[ "$DISABLED_STATUS" != "200" ]]; then
+ echo "The disabled endpoint stopped serving"
+else
+ echo "Expected the disabled endpoint to stop serving"
+ FAILED=1
+fi
+
+echo "Enabling the [$ENDPOINT_NAME] endpoint again..."
+az afd endpoint update \
+ --endpoint-name $ENDPOINT_NAME \
+ --profile-name $PROFILE_NAME \
+ --resource-group $RESOURCE_GROUP_NAME \
+ --enabled-state Enabled 1>/dev/null
+
+ENABLED_STATUS=''
+for i in $(seq 1 $PROPAGATION_ATTEMPTS); do
+ ENABLED_STATUS=$(curl -s -m 20 -o /dev/null -w "%{http_code}" "$ENDPOINT_URL/status")
+ [[ "$ENABLED_STATUS" == "200" ]] && break
+ sleep 5
+done
+echo "The re-enabled endpoint answers: HTTP $ENABLED_STATUS"
+if [[ "$ENABLED_STATUS" == "200" ]]; then
+ echo "The endpoint serves again"
+else
+ echo "Expected the re-enabled endpoint to serve again"
+ FAILED=1
+fi
+
+if [[ $FAILED == 0 ]]; then
+ echo "All validation checks passed"
+else
+ echo "Some validation checks failed"
+fi
+exit $FAILED