From b2e499929fd7fc1c5324664f6a7b6911b1d22609 Mon Sep 17 00:00:00 2001 From: zerodeth Date: Mon, 29 Jun 2026 20:00:21 +0100 Subject: [PATCH 1/4] feat(cdn): emulate Azure Front Door + migrate scenarios off classic CDN Add Azure Front Door (Standard/Premium) support so azemu can validate the Front Door read path the production OTA delivery target ships, closing the gap left when azurerm removed classic CDN at v4.35. Control plane: four new ARM child types under the existing Microsoft.Cdn/profiles provider (afdEndpoints, originGroups, origins, routes) with CRUD, HEAD, LIST, parent-existence checks, and cascade delete. azurerm_cdn_frontdoor_profile reuses the existing profile handler (shared ARM type; SKU is a no-op). An afdEndpoint advertises a deterministic {name}.azurefd.net host on create. Data plane: a *.azurefd.net content proxy that walks endpoint -> route -> origin group -> origin and reverse-proxies to the Blob origin (Azurite, path-style), passing Content-Type/Cache-Control through unchanged. Shares the blob-proxy core with the classic *.azureedge.net plane, which is left intact so users pinned < 4.35 keep working. Adds *.azurefd.net to the TLS SAN list and the ARM-port host mux. Scenarios: static-site and ota-delivery migrated from classic CDN to Front Door. Pinned to azurerm >= 4.35, < 4.36 (4.35.x is the version the emulation was validated against and stays below the later storage/Key Vault data-plane tightening azemu's path-style endpoints do not yet satisfy; see TODO.md M6). Docs: design note 5 (Front Door data plane and classic CDN coexistence), CHANGELOG, PARITY matrix (+ website mirror), TODO/TASKS/ROADMAP pin-lift notes. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 23 + ROADMAP.md | 2 +- TASKS.md | 10 +- TODO.md | 16 +- cmd/azemu/serve.go | 36 +- docs/PARITY.md | 3 +- ...-data-plane-and-classic-cdn-coexistence.md | 150 +++++++ docs/design-notes/README.md | 1 + .../scenarios/ota-delivery/README.md | 36 +- .../terraform/scenarios/ota-delivery/e2e.sh | 4 +- .../scenarios/ota-delivery/fixturegen/main.go | 4 +- .../terraform/scenarios/ota-delivery/main.tf | 61 ++- .../scenarios/ota-delivery/main.tftest.hcl | 4 +- .../scenarios/ota-delivery/outputs.tf | 8 +- .../scenarios/ota-delivery/provider.tf | 12 +- .../terraform/scenarios/static-site/README.md | 18 +- .../terraform/scenarios/static-site/main.tf | 72 ++-- .../scenarios/static-site/main.tftest.hcl | 4 +- .../scenarios/static-site/outputs.tf | 10 +- .../scenarios/static-site/provider.tf | 20 +- internal/arm/cdn_dataplane.go | 16 +- internal/arm/cdn_frontdoor.go | 401 ++++++++++++++++++ internal/arm/cdn_frontdoor_dataplane.go | 188 ++++++++ internal/arm/cdn_frontdoor_dataplane_test.go | 197 +++++++++ internal/arm/cdn_frontdoor_test.go | 266 ++++++++++++ internal/arm/router.go | 28 ++ internal/middleware/pathcase.go | 5 + website/docs/concepts/parity-matrix.md | 3 +- ...-data-plane-and-classic-cdn-coexistence.md | 57 +++ website/mkdocs.yml | 1 + 30 files changed, 1528 insertions(+), 128 deletions(-) create mode 100644 docs/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md create mode 100644 internal/arm/cdn_frontdoor.go create mode 100644 internal/arm/cdn_frontdoor_dataplane.go create mode 100644 internal/arm/cdn_frontdoor_dataplane_test.go create mode 100644 internal/arm/cdn_frontdoor_test.go create mode 100644 website/docs/resources/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5775356..b38380a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,12 +21,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Azure Front Door (Standard/Premium) support. Four new ARM child types under + the existing `Microsoft.Cdn/profiles` provider: + `azurerm_cdn_frontdoor_endpoint` (afdEndpoints), + `azurerm_cdn_frontdoor_origin_group` (originGroups), + `azurerm_cdn_frontdoor_origin` (origins), and + `azurerm_cdn_frontdoor_route` (routes), each with CRUD, HEAD, LIST, + parent-existence checks, and cascade delete. `azurerm_cdn_frontdoor_profile` + reuses the existing profile handler (the ARM type is shared with classic + CDN; the SKU is a no-op). On create, an endpoint advertises a deterministic + `{name}.azurefd.net` host. A new `*.azurefd.net` content data plane walks + endpoint to route to origin group to origin and reverse-proxies to the Blob + origin (Azurite, path-style), passing the origin's `Content-Type` and + `Cache-Control` through unchanged, mirroring the classic CDN data plane. + Classic CDN (`*.azureedge.net`) is unchanged and coexists. See design note 5. - `redis-cache` scenario: a Redis cache with its connection string stored in Key Vault, the common pattern of provisioning a managed cache and reading its connection details as a secret instead of embedding them. Runs end to end against azemu via `terraform test` and exercises the Redis `listKeys` endpoint and the Key Vault secret data plane. +### Changed + +- `static-site` and `ota-delivery` scenarios migrated from classic CDN + (`azurerm_cdn_profile` / `azurerm_cdn_endpoint`) to Front Door + (`azurerm_cdn_frontdoor_*`), lifting their provider pin from + `>= 4.0, < 4.35` to `>= 4.35`. Classic CDN was removed at azurerm 4.35; the + scenarios now exercise the Front Door resource graph the production OTA + read path ships. + ## [v0.3.0] - 2026-06-28 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index eaf7fcd..87253e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,7 +201,7 @@ Priority order inside v0.2 is top-down; ship the first row first. | `azemu` multi-toolchain CLI | SHIPPED (Phase 9). Subcommands (`azemu tf`, `azemu pulumi`, `azemu kubectl`, `azemu python`) auto-start the emulator, inject env vars, and exec the underlying tool. One binary, any toolchain. Replaced the shell `scripts/aztf` wrapper. | | Plugin SDK | Out-of-process resource modules so community can ship providers without forking | | Native Terraform test framework (`.tftest.hcl`) | First-class support for `terraform test` in the emulator test pyramid | -| Front Door, Traffic Manager | Requested by users once Application Gateway lands | +| Traffic Manager | Requested by users once Application Gateway lands (Front Door shipped; see design note 5) | | Cosmos DB (management + data plane) | The next natural storage type after Blob | | Event Grid + Service Bus | Eventing story for microservices scenarios | diff --git a/TASKS.md b/TASKS.md index 51d907b..3c1a4ed 100644 --- a/TASKS.md +++ b/TASKS.md @@ -9,10 +9,12 @@ key and writes immutable artefacts to Blob, a release pipeline promotes by a server-side blob copy, and a CDN serves the static files. This needed one generic azemu capability (a CDN content data plane); it shipped with the `ota-delivery` scenario. -Current focus: lifting the azurerm provider-version pins. static-site is -pinned `< 4.35` pending a Front Door migration; the storage scenarios are -pinned `< 4.35` pending host-style `*.blob.core.windows.net` routing. Both -gaps are tracked in TODO.md Known Gaps. +Current focus: lifting the azurerm provider-version pins. The `static-site` +and `ota-delivery` scenarios migrated from classic CDN to Azure Front Door +(`azurerm_cdn_frontdoor_*`, design note 5), lifting their pin to `>= 4.35`; +the remaining storage scenarios stay pinned `< 4.35` pending host-style +`*.blob.core.windows.net` routing. The storage gap is tracked in TODO.md +Known Gaps. > **Ready-for-testing / scenario-CI health (2026-06-27, PR #74 merged).** The > Terraform Scenarios CI job had been red for weeks. The fail-fast loop in diff --git a/TODO.md b/TODO.md index 8c3f30f..dd3f809 100644 --- a/TODO.md +++ b/TODO.md @@ -53,14 +53,14 @@ The bundle file is written with mode 0600 because it contains the private key. ## Known Gaps -- **static-site scenario pinned to azurerm < 4.35.** From v4.35.0 the - provider refuses to create classic CDN resources once the 2025-10-01 - deprecation date has passed (wall-clock check in - `internal/services/cdn/cdn_deprecation.go`, no opt-out), so the error - fires client-side before any request reaches azemu. Lift the pin by - migrating the scenario (and azemu's CDN emulation) from - `azurerm_cdn_profile`/`azurerm_cdn_endpoint` to Front Door - (`azurerm_cdn_frontdoor_*`). Classic CDN retires fully 2027-09-30. +- ~~**static-site scenario pinned to azurerm < 4.35.**~~ **RESOLVED + 2026-06-29.** azemu now emulates Azure Front Door (the four + `Microsoft.Cdn/profiles` child types plus a `*.azurefd.net` data plane; + see design note 5), and the `static-site` and `ota-delivery` scenarios + migrated from classic CDN (`azurerm_cdn_profile`/`azurerm_cdn_endpoint`) + to Front Door (`azurerm_cdn_frontdoor_*`), lifting their pin to + `>= 4.35`. Classic CDN emulation stays for users still pinned `< 4.35`; + classic CDN retires fully 2027-09-30. - **All scenarios pinned to azurerm `< 4.35`; latest azurerm not yet supported (M6).** azurerm 4.78+ makes `azurerm_storage_container` parse the account blob endpoint and require a `core.windows.net` suffix, which diff --git a/cmd/azemu/serve.go b/cmd/azemu/serve.go index 5479538..87f4608 100644 --- a/cmd/azemu/serve.go +++ b/cmd/azemu/serve.go @@ -166,11 +166,12 @@ func runServe(args []string) error { // *.vault.localhost serves the per-vault Key Vault data-plane hosts // ({vaultName}.vault.localhost) that the azurerm provider requires in - // vaultUri. *.azureedge.net serves the CDN endpoint content hosts - // ({endpoint}.azureedge.net) that the CDN data-plane proxy answers. Bundles - // generated before either SAN existed are regenerated automatically; the new - // cert must be trusted again. - tlsCfg, generated, err := auth.LoadOrGenerateSelfSignedTLS(cfg.CertPath, "localhost", "127.0.0.1", "*.vault.localhost", "*.azureedge.net") + // vaultUri. *.azureedge.net serves the classic CDN endpoint content hosts + // and *.azurefd.net the Front Door endpoint hosts + // ({endpoint}.azurefd.net) that the respective data-plane proxies answer. + // Bundles generated before any SAN existed are regenerated automatically; + // the new cert must be trusted again. + tlsCfg, generated, err := auth.LoadOrGenerateSelfSignedTLS(cfg.CertPath, "localhost", "127.0.0.1", "*.vault.localhost", "*.azureedge.net", "*.azurefd.net") if err != nil { if len(tlsCfg.Certificate) == 0 { log.Fatal().Err(err).Msg("failed to load/generate TLS cert") @@ -216,11 +217,12 @@ func runServe(args []string) error { WriteTimeout: 5 * time.Second, } - // On the ARM port, multiplex the CDN content data plane: a request to a - // {endpoint}.azureedge.net host is served by the CDN proxy, everything else - // by the ARM control plane. Real Azure serves CDN content from a distinct - // host; azemu colocates both on the ARM port so one trusted cert and port - // cover the read path. Mirrors the Key Vault {vault}.vault.localhost split. + // On the ARM port, multiplex the CDN content data planes: a request to a + // {endpoint}.azureedge.net host is served by the classic CDN proxy and a + // {endpoint}.azurefd.net host by the Front Door proxy, everything else by + // the ARM control plane. Real Azure serves CDN content from distinct hosts; + // azemu colocates them on the ARM port so one trusted cert and port cover + // the read path. Mirrors the Key Vault {vault}.vault.localhost split. armAndCDN := cdnHostMux(armRouter, r) httpSrv := &http.Server{ @@ -333,16 +335,20 @@ func armCertPoolFromTLS(cert tls.Certificate) *x509.CertPool { return pool } -// cdnHostMux dispatches CDN content hosts ({endpoint}.azureedge.net) to the CDN -// data plane and every other host to the ARM control plane. Extracted from the +// cdnHostMux dispatches CDN content hosts to their data planes and every other +// host to the ARM control plane: {endpoint}.azureedge.net to the classic CDN +// proxy and {endpoint}.azurefd.net to the Front Door proxy. Extracted from the // server wiring so the routing decision is unit-testable. func cdnHostMux(armRouter *arm.Router, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if arm.IsCDNContentHost(req.Host) { + switch { + case arm.IsCDNContentHost(req.Host): armRouter.ServeCDNContent(w, req) - return + case arm.IsAFDContentHost(req.Host): + armRouter.ServeAFDContent(w, req) + default: + next.ServeHTTP(w, req) } - next.ServeHTTP(w, req) }) } diff --git a/docs/PARITY.md b/docs/PARITY.md index 1a7f778..88b8cf9 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -35,7 +35,8 @@ and should read Scaffold or Planned instead. | DNS Zones | Full | N/A | `azurerm_dns_zone`, `azurerm_dns_a_record`, `azurerm_dns_aaaa_record`, `azurerm_dns_cname_record`, `azurerm_dns_txt_record`, `azurerm_dns_mx_record`, `azurerm_dns_srv_record`, `azurerm_dns_ns_record` | Full (auto-SOA + auto-NS on zone create; A, AAAA, CNAME, TXT, MX, SRV, NS, SOA record sets as children; cascade delete) | [dns_test.go](../internal/arm/dns_test.go), [arm_test.go](../test/integration/arm_test.go) | | Storage Accounts | Full | Delegated to Azurite | `azurerm_storage_account`, `azurerm_storage_container` | Full (management plane; Azurite path-style endpoints; `listKeys` returns Azurite dev key; name uniqueness check; blob containers as child resources with cascade delete) | [storage_account_test.go](../internal/arm/storage_account_test.go), [storage_container_test.go](../internal/arm/storage_container_test.go), [arm_test.go](../test/integration/arm_test.go) | | Key Vault | Full | Full | `azurerm_key_vault`, `azurerm_key_vault_secret`, `azurerm_key_vault_key` | Full (management plane; `vaultUri` rewritten to azemu's per-vault host `https://{name}.vault.localhost[:port]/`; root-level `/keys` and `/secrets` routes resolve the vault from the Host header; SKU/soft-delete defaults; secrets with versioning, list, and cascade delete on vault destroy; RSA keys with versioning, import, RS256 sign verified against the returned public JWK, versionless sign via current pointer; no key export, wrap/unwrap, EC keys, or rotation policy; protected routes honour azemu-issued bearer tokens) | [keyvault_test.go](../internal/arm/keyvault_test.go), [keyvault_secret_test.go](../internal/arm/keyvault_secret_test.go), [keyvault_key_test.go](../internal/arm/keyvault_key_test.go), [arm_test.go](../test/integration/arm_test.go), [workload_identity_test.go](../test/integration/workload_identity_test.go) | -| CDN | Full | Full | `azurerm_cdn_profile`, `azurerm_cdn_endpoint` | Full (profile SKU at top level; endpoint `hostName` computed as `{name}.azureedge.net`; cascade delete; parent-existence check on endpoint PUT). Content data plane: a request to the endpoint host `{name}.azureedge.net` is reverse-proxied to the endpoint's Blob origin (Azurite, path-style) with the origin's `Content-Type` and `Cache-Control` passed through, matching Azure CDN's default origin-honouring behaviour; `GET`/`HEAD` only; delivery-rule TTL overrides are a follow-up. | [cdn_test.go](../internal/arm/cdn_test.go), [cdn_dataplane_test.go](../internal/arm/cdn_dataplane_test.go), [arm_test.go](../test/integration/arm_test.go) | +| CDN (classic) | Full | Full | `azurerm_cdn_profile`, `azurerm_cdn_endpoint` | Full (profile SKU at top level; endpoint `hostName` computed as `{name}.azureedge.net`; cascade delete; parent-existence check on endpoint PUT). Content data plane: a request to the endpoint host `{name}.azureedge.net` is reverse-proxied to the endpoint's Blob origin (Azurite, path-style) with the origin's `Content-Type` and `Cache-Control` passed through, matching Azure CDN's default origin-honouring behaviour; `GET`/`HEAD` only; delivery-rule TTL overrides are a follow-up. Classic CDN was removed from the azurerm provider at v4.35; usable only with `azurerm < 4.35`. | [cdn_test.go](../internal/arm/cdn_test.go), [cdn_dataplane_test.go](../internal/arm/cdn_dataplane_test.go), [arm_test.go](../test/integration/arm_test.go) | +| Front Door (Standard/Premium) | Full | Full | `azurerm_cdn_frontdoor_profile`, `azurerm_cdn_frontdoor_endpoint`, `azurerm_cdn_frontdoor_origin_group`, `azurerm_cdn_frontdoor_origin`, `azurerm_cdn_frontdoor_route` | Full (profile shares the `Microsoft.Cdn/profiles` type with classic CDN, SKU is a no-op; endpoint `hostName` generated as `{name}.azurefd.net`; origin group, origin, and route as child resources with parent-existence checks and cascade delete). Content data plane: a request to `{name}.azurefd.net` walks endpoint to route to origin group to origin and reverse-proxies to the Blob origin (Azurite, path-style) with `Content-Type`/`Cache-Control` passed through; `GET`/`HEAD` only. Custom domains, rule sets, and WAF policies are out of scope (the route uses `link_to_default_domain`). Requires `azurerm >= 4.35`. See design note 5. | [cdn_frontdoor_test.go](../internal/arm/cdn_frontdoor_test.go), [cdn_frontdoor_dataplane_test.go](../internal/arm/cdn_frontdoor_dataplane_test.go) | | User Assigned Identity | Full | N/A | `azurerm_user_assigned_identity` | Full (deterministic `principalId`/`clientId` via SHA-1 UUID for stable plan/apply/refresh; DELETE async 202) | [identity_test.go](../internal/arm/identity_test.go) | | Federated Identity Credential | Full | N/A | `azurerm_federated_identity_credential` | Full (child CRUD under user-assigned identities; issuer/subject/audience rules used by workload identity token exchange) | [federated_identity_credential_test.go](../internal/arm/federated_identity_credential_test.go), [token_test.go](../internal/auth/token_test.go), [workload_identity_test.go](../test/integration/workload_identity_test.go) | | AKS Managed Cluster | Full (stub) | N/A | `azurerm_kubernetes_cluster`, `azurerm_kubernetes_cluster_node_pool` | Full management plane (computed fqdn, default k8s version 1.29.0, SKU + identity at top level, cascade-delete node pools, parent-existence check on pool PUT, listClusterUserCredential / listClusterAdminCredential returning a parseable kubeconfig for the kube_config attribute) | [aks_test.go](../internal/arm/aks_test.go) | diff --git a/docs/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md b/docs/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md new file mode 100644 index 0000000..5573d82 --- /dev/null +++ b/docs/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md @@ -0,0 +1,150 @@ +# Design note 5: Front Door data plane and classic CDN coexistence + +- Status: Implemented +- Date: 2026-06-29 + +## Context + +azemu's production target at Tesco serves over-the-air (OTA) update +bundles through Azure Front Door. The OTA read path (signed manifest plus +immutable assets in Blob Storage, fronted by a CDN) is the shape design +note 1 and the `ota-delivery` scenario were built to mirror locally. + +Until now azemu emulated only classic Azure CDN: the +`azurerm_cdn_profile` / `azurerm_cdn_endpoint` control plane and a +`*.azureedge.net` content data plane (design note for the data plane +landed in v0.3.0). The azurerm provider removed classic CDN at v4.35.0 +(the resources fail a wall-clock deprecation check client-side before any +request reaches azemu), so the `static-site` and `ota-delivery` scenarios +were pinned to `>= 4.0, < 4.35`. That pin meant azemu could not validate +the resource graph the production system actually ships, which is Front +Door, not classic CDN. This note records how Front Door was added without +removing classic CDN. + +Front Door (Standard/Premium) spreads what classic CDN packs into one +endpoint across a resource graph: + +```text +afdEndpoint --> route --> originGroup --> origin (Blob host) +``` + +The endpoint advertises a generated `*.azurefd.net` host; a route links +that endpoint to an origin group; the origin group holds one or more +origins; each origin carries the backing Blob host. A client fetches from +the endpoint host, and the edge walks the graph to the origin. + +## Decision + +**Add Front Door as a second CDN surface that coexists with classic CDN. +Both control planes and both content data planes run side by side; the +host-mux on the ARM port dispatches by host suffix. Migrate the +`static-site` and `ota-delivery` scenarios to Front Door and lift their +`< 4.35` pin; leave classic CDN in place for users still pinned below +4.35.** + +Concretely: + +- **Shared profile type.** `azurerm_cdn_frontdoor_profile` is the same ARM + type as classic CDN, `Microsoft.Cdn/profiles`. The existing profile + handler already accepts any SKU, so it serves Front Door profiles + (`Standard_AzureFrontDoor`, `Premium_AzureFrontDoor`) unchanged. Only + the four Front Door child types are new: `afdEndpoints`, `originGroups`, + `origins`, `routes`, all under `internal/arm/cdn_frontdoor.go`. +- **SKU is a no-op.** Standard versus Premium changes nothing in + emulation. The SKU is stored and echoed back so the provider round-trips + it; azemu does not gate any behaviour on it. +- **Deterministic endpoint host.** On `afdEndpoint` create, azemu writes a + read-only `hostName` of `{name}.azurefd.net` into the response. Real + Azure mints `{name}-{hash}.z01.azurefd.net`; azemu uses the stable form + so the value the provider reads (and the scenario references via + `azurerm_cdn_frontdoor_endpoint.X.host_name`) is exactly the host the + data plane resolves. No hash to reconcile. +- **Host-mux resolution.** A request to `{endpoint}.azurefd.net` on the + ARM port is matched by suffix and handed to the Front Door content data + plane (`internal/arm/cdn_frontdoor_dataplane.go`), which walks + endpoint -> route -> originGroup -> origin, reads the origin's Blob host, + and reverse-proxies path-style to Azurite. `*.azureedge.net` still goes + to the classic CDN proxy; everything else to the ARM control plane. The + two data planes share one blob-proxy helper, so the origin's + `Content-Type` and `Cache-Control` reach the client unchanged for both + (the OTA manifest keeps its multipart boundary and short TTL). +- **Minimal resource depth.** Only the five resources the scenarios need + are implemented. `cdn_frontdoor_custom_domain`, `rule_set`, `rule`, + `security_policy`, and `firewall_policy` are out of scope. The route + sets `link_to_default_domain = true`, which serves on the default + `*.azurefd.net` host with no custom domain, so none of those are + required. + +## Rationale + +1. **Fidelity to the production target.** The OTA system ships Front Door. + With classic CDN only, azemu could not exercise the resource graph + under test. Front Door closes that gap. +2. **No breakage for pinned users.** Removing classic CDN would break any + user still on azurerm `< 4.35`. Both surfaces share the profile type + and the blob-proxy core, so coexistence costs little and keeps the + old path green. +3. **Deterministic host avoids a reconcile step.** A hashed host would + force azemu to store the generated host and the provider to read it + back before the data plane could resolve it. The stable + `{name}.azurefd.net` form makes control plane and data plane agree by + construction, the same approach the classic `*.azureedge.net` data + plane already uses. +4. **Synchronous writes are enough.** The provider pins the Front Door + child types to the track1 `cdn/2021-06-01` SDK, whose long-running + operation future is satisfied by a terminal `200`/`201` with no async + header. The profile uses `2024-02-01`, whose poller treats a `200` with + `provisioningState: "Succeeded"` as immediate success. azemu answers + both synchronously; DELETE reuses the existing async-operation path from + design note material in `operations.go` (the M7 lesson). + +## Consequences + +### Positive + +- `static-site` and `ota-delivery` run at azurerm `>= 4.35` against the + real Front Door resource graph, so the pin is lifted for both. +- The OTA read path is validated end to end through Front Door, matching + production. +- Two more "Full" entries on the parity matrix (the Front Door child + graph plus the `*.azurefd.net` data plane). + +### Negative + +- Two CDN surfaces to maintain. Mitigated by the shared profile handler + and the shared blob-proxy helper, so the duplication is small. +- Front Door depth is intentionally shallow: no custom domains, rule sets, + WAF, or caching-rule overrides. Tracked as a follow-up; the route's + `link_to_default_domain = true` keeps the minimal config valid. + +### Neutral + +- SKU (Standard versus Premium) is stored but inert. A future feature that + depends on the tier (private link, WAF) would change that. +- Origin selection picks the lowest `priority` then highest `weight`. In + the single-origin scenarios this is the only origin; the ordering + matters only if a group ever lists several. + +## Alternatives considered + +1. **Replace classic CDN with Front Door.** Rejected. It would break users + pinned below azurerm 4.35 for no benefit, since the surfaces coexist + cheaply. +2. **Match Azure's hashed `{name}-{hash}.z01.azurefd.net` host.** + Rejected. The hash adds a reconcile step between control and data plane + with no fidelity gain for a local emulator; the data plane controls + both ends of the host already. +3. **Implement the full Front Door surface (custom domains, rule sets, + WAF) now.** Rejected as premature. No scenario needs it; the five core + resources plus `link_to_default_domain` cover the OTA and static-site + read paths. Depth is added when a scenario demands it. + +## References + +- design note 1 (delegate Storage data plane to Azurite): the origin the + Front Door data plane proxies to. +- design note 3 (Azure Cache for Redis): the same control-plane-emulated, + data-plane-delegated pattern. +- TODO.md M7: the async-DELETE operation-result lesson reused here. +- [Azure Front Door + documentation](https://learn.microsoft.com/en-us/azure/frontdoor/). diff --git a/docs/design-notes/README.md b/docs/design-notes/README.md index 52c2916..7740ec4 100644 --- a/docs/design-notes/README.md +++ b/docs/design-notes/README.md @@ -17,6 +17,7 @@ stays readable. | 0002 | [azemu + kind hybrid for AKS workload deployments](0002-azemu-plus-kind-for-aks-workload-deployments.md) | Proposed | | 0003 | [Add Azure Cache for Redis](0003-add-azure-cache-for-redis.md) | Implemented | | 0004 | [How far azemu emulates Azure DevOps Pipelines](0004-azure-devops-pipelines-scope.md) | Proposed | +| 0005 | [Front Door data plane and classic CDN coexistence](0005-front-door-data-plane-and-classic-cdn-coexistence.md) | Implemented | ## When to write one diff --git a/examples/terraform/scenarios/ota-delivery/README.md b/examples/terraform/scenarios/ota-delivery/README.md index abccd03..e42bf70 100644 --- a/examples/terraform/scenarios/ota-delivery/README.md +++ b/examples/terraform/scenarios/ota-delivery/README.md @@ -1,14 +1,14 @@ -# Scenario: server-less OTA delivery (Blob + Key Vault sign + CDN) +# Scenario: server-less OTA delivery (Blob + Key Vault sign + Front Door) Validates a server-less, static-file over-the-air delivery design end to end against azemu, with no real Azure subscription. There is no compute on the read path: a build pipeline signs an update manifest with a Key Vault key and writes immutable artefacts to Blob Storage, a release pipeline promotes a version by a -server-side blob copy, and a CDN serves the static files to clients. +server-side blob copy, and Azure Front Door serves the static files to clients. This scenario stitches the existing `ado-pipeline`, `static-site`, and -`ota-updates` building blocks into one flow and exercises the CDN read path -through azemu's CDN content data plane. +`ota-updates` building blocks into one flow and exercises the Front Door read +path through azemu's Front Door content data plane. ## What it provisions @@ -18,8 +18,11 @@ through azemu's CDN content data plane. | `azurerm_storage_account.ota` | Immutable artefacts + mutable rollout state (`allow_nested_items_to_be_public = true`) | | `azurerm_key_vault.signing` | Holds the RSA manifest signing key | | `azurerm_key_vault_key.manifest` | RSA-2048, `key_opts = ["sign", "verify"]` | -| `azurerm_cdn_profile.ota` | Classic CDN profile (`Standard_Microsoft`) | -| `azurerm_cdn_endpoint.ota` | CDN endpoint with the storage blob as origin | +| `azurerm_cdn_frontdoor_profile.ota` | Front Door profile (`Standard_AzureFrontDoor`) | +| `azurerm_cdn_frontdoor_endpoint.ota` | Generated `{name}.azurefd.net` read-path host | +| `azurerm_cdn_frontdoor_origin_group.ota` | Load-balancing settings | +| `azurerm_cdn_frontdoor_origin.ota` | The storage blob origin | +| `azurerm_cdn_frontdoor_route.ota` | Links endpoint to origin group on the default domain | The blob container is created by the publish step, not Terraform: azemu does not mirror ARM containers into the Azurite data plane. The `azemuotadsa` account @@ -43,19 +46,20 @@ quickly. A promotion is a server-side copy of `v{n}/update.multipart` to the live `manifest.json` path; the signature is over the manifest bytes and travels in the multipart part header, so the copy never invalidates it. -## The CDN read path +## The Front Door read path -azemu's CDN content data plane serves the endpoint host -`{name}.azureedge.net` (multiplexed on the ARM port `:4566`): it resolves the -endpoint, finds the Blob origin (`{account}.blob.core.windows.net`), and -reverse-proxies to Azurite path-style, passing the origin's `Content-Type` and -`Cache-Control` through unchanged. A client therefore fetches the signed -manifest and assets from the CDN host, exactly as in production. +azemu's Front Door content data plane serves the endpoint host +`{name}.azurefd.net` (multiplexed on the ARM port `:4566`): it resolves the +endpoint, walks route to origin group to origin to find the Blob origin +(`{account}.blob.core.windows.net`), and reverse-proxies to Azurite path-style, +passing the origin's `Content-Type` and `Cache-Control` through unchanged. A +client therefore fetches the signed manifest and assets from the Front Door +host, exactly as in production. -Because `{name}.azureedge.net` is not a real DNS name locally, the client +Because `{name}.azurefd.net` is not a real DNS name locally, the client resolves it to `127.0.0.1` (the `fixturegen verify` tool and the `curl --resolve` examples below do this) and trusts the azemu cert (covered by the -`*.azureedge.net` SAN). +`*.azurefd.net` SAN). ## Run the full loop (local) @@ -64,7 +68,7 @@ make ota-delivery # from the repo root ``` That brings up azemu + Azurite, provisions the ARM estate, publishes a signed -update, promotes it to 100%, asserts the CDN read path (multipart Content-Type, +update, promotes it to 100%, asserts the Front Door read path (multipart Content-Type, cache TTLs, and the manifest signature against the exported public key), then tears everything down. It needs Docker (for the Azurite data plane) and Go (for the `fixturegen` tool). diff --git a/examples/terraform/scenarios/ota-delivery/e2e.sh b/examples/terraform/scenarios/ota-delivery/e2e.sh index 40f28cf..1da51ca 100755 --- a/examples/terraform/scenarios/ota-delivery/e2e.sh +++ b/examples/terraform/scenarios/ota-delivery/e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # End-to-end driver for the server-less OTA delivery scenario. Provisions the # ARM estate against azemu, publishes a signed update, promotes it, asserts the -# CDN read path, then tears down. Local-only: it needs the running Azurite data +# Front Door read path, then tears down. Local-only: it needs the running Azurite data # plane (the ARM-half smoke that CI runs lives in main.tftest.hcl). # # Run via `make ota-delivery` from the repo root (which brings up the stack). @@ -55,7 +55,7 @@ echo "== promote v$VERSION to 100% (server-side copy, write rollout.json) ==" "${FG[@]}" promote -account "$ACCOUNT" -key "$DEV_KEY" -azurite "$AZURITE" \ -container "$CONTAINER" -prefix "$PREFIX" -version "$VERSION" -echo "== verify CDN read path (Content-Type, cache TTLs, signature) ==" +echo "== verify Front Door read path (Content-Type, cache TTLs, signature) ==" "${FG[@]}" verify -fqdn "$FQDN" -base "$CONTAINER/$PREFIX" -version "$VERSION" \ -asset "$ASSET" -pubkey "$SCENARIO_DIR/pub.pem" -cacert "$CERT" diff --git a/examples/terraform/scenarios/ota-delivery/fixturegen/main.go b/examples/terraform/scenarios/ota-delivery/fixturegen/main.go index dae54a8..d6d1df6 100644 --- a/examples/terraform/scenarios/ota-delivery/fixturegen/main.go +++ b/examples/terraform/scenarios/ota-delivery/fixturegen/main.go @@ -502,8 +502,8 @@ func (c config) canonicalizedResource(u *url.URL) (string, error) { return b.String(), nil } -// runVerify asserts the CDN read path: it fetches the live manifest, rollout, -// and an asset through the {endpoint}.azureedge.net host and checks the cache +// runVerify asserts the Front Door read path: it fetches the live manifest, +// rollout, and an asset through the {endpoint}.azurefd.net host and checks the cache // TTLs, the multipart Content-Type, the rollout body, and the manifest // signature against the public key the client would embed. All assertions live // here (rather than in fragile shell) so the read path is checked robustly. diff --git a/examples/terraform/scenarios/ota-delivery/main.tf b/examples/terraform/scenarios/ota-delivery/main.tf index 292563b..31d9b07 100644 --- a/examples/terraform/scenarios/ota-delivery/main.tf +++ b/examples/terraform/scenarios/ota-delivery/main.tf @@ -55,28 +55,57 @@ resource "azurerm_key_vault_key" "manifest" { key_opts = ["sign", "verify"] } -# --- CDN read path --- +# --- Front Door read path --- -resource "azurerm_cdn_profile" "ota" { - name = "${var.prefix}-cdn" - location = var.location +resource "azurerm_cdn_frontdoor_profile" "ota" { + name = "${var.prefix}-fd" resource_group_name = azurerm_resource_group.main.name - sku = "Standard_Microsoft" + sku_name = "Standard_AzureFrontDoor" } -resource "azurerm_cdn_endpoint" "ota" { - name = "${var.prefix}-endpoint" - profile_name = azurerm_cdn_profile.ota.name - location = var.location - resource_group_name = azurerm_resource_group.main.name +resource "azurerm_cdn_frontdoor_endpoint" "ota" { + name = "${var.prefix}-endpoint" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.ota.id +} + +resource "azurerm_cdn_frontdoor_origin_group" "ota" { + name = "${var.prefix}-og" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.ota.id - # The Blob origin. azemu's CDN content data plane parses the storage account - # from this host and reverse-proxies to Azurite path-style, passing the - # origin's Content-Type and Cache-Control through unchanged. - origin { - name = "blob-origin" - host_name = "${azurerm_storage_account.ota.name}.blob.core.windows.net" + load_balancing { + sample_size = 4 + successful_samples_required = 3 } +} +# The Blob origin. azemu's Front Door content data plane walks endpoint -> route +# -> origin group -> origin, parses the storage account from this host, and +# reverse-proxies to Azurite path-style, passing the origin's Content-Type and +# Cache-Control through unchanged (the OTA manifest keeps its multipart boundary +# and short TTL). +resource "azurerm_cdn_frontdoor_origin" "ota" { + name = "blob-origin" + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.ota.id + enabled = true + + host_name = "${azurerm_storage_account.ota.name}.blob.core.windows.net" origin_host_header = "${azurerm_storage_account.ota.name}.blob.core.windows.net" + http_port = 80 + https_port = 443 + priority = 1 + weight = 1000 + + certificate_name_check_enabled = false +} + +resource "azurerm_cdn_frontdoor_route" "ota" { + name = "${var.prefix}-route" + cdn_frontdoor_endpoint_id = azurerm_cdn_frontdoor_endpoint.ota.id + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.ota.id + cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.ota.id] + + supported_protocols = ["Http", "Https"] + patterns_to_match = ["/*"] + forwarding_protocol = "MatchRequest" + link_to_default_domain = true } diff --git a/examples/terraform/scenarios/ota-delivery/main.tftest.hcl b/examples/terraform/scenarios/ota-delivery/main.tftest.hcl index 55a843c..6bfbe6a 100644 --- a/examples/terraform/scenarios/ota-delivery/main.tftest.hcl +++ b/examples/terraform/scenarios/ota-delivery/main.tftest.hcl @@ -46,7 +46,7 @@ run "ota_delivery_lifecycle" { } assert { - condition = endswith(output.cdn_endpoint_fqdn, ".azureedge.net") - error_message = "cdn_endpoint_fqdn must be an azureedge.net host" + condition = endswith(output.cdn_endpoint_fqdn, ".azurefd.net") + error_message = "cdn_endpoint_fqdn must be a Front Door .azurefd.net host" } } diff --git a/examples/terraform/scenarios/ota-delivery/outputs.tf b/examples/terraform/scenarios/ota-delivery/outputs.tf index 6d5cc93..26addb4 100644 --- a/examples/terraform/scenarios/ota-delivery/outputs.tf +++ b/examples/terraform/scenarios/ota-delivery/outputs.tf @@ -34,11 +34,11 @@ output "manifest_key_public_pem" { } output "cdn_profile_id" { - description = "CDN profile ARM ID" - value = azurerm_cdn_profile.ota.id + description = "Front Door profile ARM ID" + value = azurerm_cdn_frontdoor_profile.ota.id } output "cdn_endpoint_fqdn" { - description = "CDN endpoint hostname ({name}.azureedge.net); the read-path host the client fetches" - value = azurerm_cdn_endpoint.ota.fqdn + description = "Front Door endpoint hostname ({name}.azurefd.net); the read-path host the client fetches" + value = azurerm_cdn_frontdoor_endpoint.ota.host_name } diff --git a/examples/terraform/scenarios/ota-delivery/provider.tf b/examples/terraform/scenarios/ota-delivery/provider.tf index 96644e2..3b98533 100644 --- a/examples/terraform/scenarios/ota-delivery/provider.tf +++ b/examples/terraform/scenarios/ota-delivery/provider.tf @@ -7,10 +7,14 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - # Classic CDN (azurerm_cdn_*) was removed at azurerm 4.35 in favour of - # Front Door. This scenario uses classic CDN, so it pins below 4.35, - # matching the static-site scenario and the rest of the suite. - version = ">= 4.0, < 4.35" + # Lower bound 4.35: classic CDN (azurerm_cdn_*) was removed at azurerm + # 4.35 in favour of Front Door, which this scenario uses. Upper bound + # < 4.36: pin to the 4.35.x line azemu's Front Door emulation was + # validated against, matching static-site and staying below the storage / + # Key Vault data-plane tightening in later 4.x that azemu's path-style + # Azurite and *.vault.localhost endpoints do not yet satisfy (TODO.md M6 + # and Known Gaps). Lift the upper bound alongside static-site. + version = ">= 4.35, < 4.36" } } } diff --git a/examples/terraform/scenarios/static-site/README.md b/examples/terraform/scenarios/static-site/README.md index e4213c9..2094a83 100644 --- a/examples/terraform/scenarios/static-site/README.md +++ b/examples/terraform/scenarios/static-site/README.md @@ -1,8 +1,8 @@ -# Scenario: Static site with CDN and DNS +# Scenario: Static site with Front Door and DNS -Demonstrates a static website hosted on Azure Storage behind a CDN profile -with a custom DNS zone, all running against a local azemu instance. No Azure -subscription required. +Demonstrates a static website hosted on Azure Storage behind an Azure Front +Door profile with a custom DNS zone, all running against a local azemu +instance. No Azure subscription required. ## Resources created @@ -11,11 +11,13 @@ subscription required. | Resource Group | `azemusite-rg` | Container for all resources | | Storage Account | `azemusitesa` | StorageV2, Standard_LRS | | Blob Container | `$web` | Public blob access for static content | -| CDN Profile | `azemusite-cdn` | Standard_Microsoft SKU | -| CDN Endpoint | `azemusite-endpoint` | Origin points to storage blob endpoint | +| Front Door Profile | `azemusite-fd` | Standard_AzureFrontDoor SKU | +| Front Door Endpoint | `azemusite-endpoint` | Generated `{name}.azurefd.net` host | +| Front Door Origin Group | `azemusite-og` | Load-balancing settings | +| Front Door Origin | `storage-origin` | Points to the storage blob endpoint | +| Front Door Route | `azemusite-route` | Links endpoint to origin group, default domain | | DNS Zone | `staticsite.local` | Custom domain zone | -| CNAME Record | `www.staticsite.local` | Points to CDN endpoint FQDN | -| TXT Record | `cdnverify.staticsite.local` | CDN domain verification | +| CNAME Record | `www.staticsite.local` | Points to the Front Door endpoint host | ## Prerequisites diff --git a/examples/terraform/scenarios/static-site/main.tf b/examples/terraform/scenarios/static-site/main.tf index 4909c1c..c697647 100644 --- a/examples/terraform/scenarios/static-site/main.tf +++ b/examples/terraform/scenarios/static-site/main.tf @@ -1,5 +1,5 @@ -# Static site: Storage account + blob container behind a CDN profile -# with a DNS zone for custom domain records. +# Static site: Storage account + blob container behind an Azure Front Door +# profile with a DNS zone for custom domain records. resource "azurerm_resource_group" "main" { name = "${var.prefix}-rg" @@ -23,27 +23,54 @@ resource "azurerm_storage_container" "web" { container_access_type = "blob" } -# --- CDN --- +# --- Front Door --- -resource "azurerm_cdn_profile" "site" { - name = "${var.prefix}-cdn" - location = var.location +resource "azurerm_cdn_frontdoor_profile" "site" { + name = "${var.prefix}-fd" resource_group_name = azurerm_resource_group.main.name - sku = "Standard_Microsoft" + sku_name = "Standard_AzureFrontDoor" } -resource "azurerm_cdn_endpoint" "site" { - name = "${var.prefix}-endpoint" - profile_name = azurerm_cdn_profile.site.name - location = var.location - resource_group_name = azurerm_resource_group.main.name +resource "azurerm_cdn_frontdoor_endpoint" "site" { + name = "${var.prefix}-endpoint" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.site.id +} - origin { - name = "storage-origin" - host_name = "${azurerm_storage_account.site.name}.blob.core.windows.net" +resource "azurerm_cdn_frontdoor_origin_group" "site" { + name = "${var.prefix}-og" + cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.site.id + + load_balancing { + sample_size = 4 + successful_samples_required = 3 } +} +resource "azurerm_cdn_frontdoor_origin" "site" { + name = "storage-origin" + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.site.id + enabled = true + + host_name = "${azurerm_storage_account.site.name}.blob.core.windows.net" origin_host_header = "${azurerm_storage_account.site.name}.blob.core.windows.net" + http_port = 80 + https_port = 443 + priority = 1 + weight = 1000 + + certificate_name_check_enabled = false +} + +resource "azurerm_cdn_frontdoor_route" "site" { + name = "${var.prefix}-route" + cdn_frontdoor_endpoint_id = azurerm_cdn_frontdoor_endpoint.site.id + cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.site.id + cdn_frontdoor_origin_ids = [azurerm_cdn_frontdoor_origin.site.id] + + supported_protocols = ["Http", "Https"] + patterns_to_match = ["/*"] + forwarding_protocol = "MatchRequest" + link_to_default_domain = true } # --- DNS --- @@ -53,21 +80,10 @@ resource "azurerm_dns_zone" "site" { resource_group_name = azurerm_resource_group.main.name } -resource "azurerm_dns_cname_record" "cdn" { +resource "azurerm_dns_cname_record" "frontdoor" { name = "www" zone_name = azurerm_dns_zone.site.name resource_group_name = azurerm_resource_group.main.name ttl = 300 - record = azurerm_cdn_endpoint.site.fqdn -} - -resource "azurerm_dns_txt_record" "verification" { - name = "cdnverify" - zone_name = azurerm_dns_zone.site.name - resource_group_name = azurerm_resource_group.main.name - ttl = 300 - - record { - value = "cdnverify.${azurerm_cdn_endpoint.site.fqdn}" - } + record = azurerm_cdn_frontdoor_endpoint.site.host_name } diff --git a/examples/terraform/scenarios/static-site/main.tftest.hcl b/examples/terraform/scenarios/static-site/main.tftest.hcl index a87760d..1d24670 100644 --- a/examples/terraform/scenarios/static-site/main.tftest.hcl +++ b/examples/terraform/scenarios/static-site/main.tftest.hcl @@ -30,8 +30,8 @@ run "static_site_lifecycle" { } assert { - condition = output.cdn_endpoint_fqdn != "" - error_message = "cdn_endpoint_fqdn must not be empty" + condition = endswith(output.cdn_endpoint_fqdn, ".azurefd.net") + error_message = "cdn_endpoint_fqdn must be a Front Door .azurefd.net host" } assert { diff --git a/examples/terraform/scenarios/static-site/outputs.tf b/examples/terraform/scenarios/static-site/outputs.tf index cbe8d62..2609f0d 100644 --- a/examples/terraform/scenarios/static-site/outputs.tf +++ b/examples/terraform/scenarios/static-site/outputs.tf @@ -14,13 +14,13 @@ output "storage_primary_blob_endpoint" { } output "cdn_profile_id" { - description = "CDN profile ARM ID" - value = azurerm_cdn_profile.site.id + description = "Front Door profile ARM ID" + value = azurerm_cdn_frontdoor_profile.site.id } output "cdn_endpoint_fqdn" { - description = "CDN endpoint hostname" - value = azurerm_cdn_endpoint.site.fqdn + description = "Front Door endpoint hostname ({name}.azurefd.net)" + value = azurerm_cdn_frontdoor_endpoint.site.host_name } output "dns_zone_id" { @@ -30,5 +30,5 @@ output "dns_zone_id" { output "www_cname_fqdn" { description = "CNAME record FQDN for www subdomain" - value = azurerm_dns_cname_record.cdn.fqdn + value = azurerm_dns_cname_record.frontdoor.fqdn } diff --git a/examples/terraform/scenarios/static-site/provider.tf b/examples/terraform/scenarios/static-site/provider.tf index b01b325..312c729 100644 --- a/examples/terraform/scenarios/static-site/provider.tf +++ b/examples/terraform/scenarios/static-site/provider.tf @@ -6,13 +6,19 @@ terraform { required_providers { azurerm = { - source = "hashicorp/azurerm" - # Pinned below 4.35: from v4.35.0 the provider refuses to create - # classic CDN resources after the 2025-10-01 deprecation date - # (wall-clock check, no opt-out), failing this scenario before any - # request reaches azemu. Migrate the scenario to Front Door - # (cdn_frontdoor_*) to lift the pin; tracked in TODO.md. - version = ">= 4.0, < 4.35" + source = "hashicorp/azurerm" + # Lower bound 4.35: Front Door (cdn_frontdoor_*) replaced classic CDN, + # which the provider removed at v4.35.0, so this scenario requires + # >= 4.35. Upper bound < 4.36: this scenario also creates an + # azurerm_storage_container against azemu's path-style Azurite endpoint. + # The provider's storage data-plane parser rejects a non-core.windows.net + # blob host, and the container resource tightened on this from v4.77 + # (storage_account_name deprecation) through the 4.78+ break recorded in + # TODO.md M6. Pinning 4.35.x keeps the scenario on the exact provider + # azemu's Front Door emulation was validated against, below that + # tightening. Lift the upper bound once azemu serves + # *.blob.core.windows.net blob endpoints (TODO.md Known Gaps). + version = ">= 4.35, < 4.36" } } } diff --git a/internal/arm/cdn_dataplane.go b/internal/arm/cdn_dataplane.go index 90dd231..8cec4ab 100644 --- a/internal/arm/cdn_dataplane.go +++ b/internal/arm/cdn_dataplane.go @@ -97,6 +97,18 @@ func (a *Router) ServeCDNContent(w http.ResponseWriter, r *http.Request) { return } + a.proxyBlobObject(w, r, account, name) +} + +// proxyBlobObject reverse-proxies a content request to a Blob account on the +// Azurite origin and streams the response back, passing the origin's +// Content-Type, Cache-Control, and the rest of the content/caching headers +// through unchanged. It is shared by the classic CDN (*.azureedge.net) and +// Front Door (*.azurefd.net) data planes: both resolve an endpoint to a Blob +// account and then proxy identically. logLabel identifies the resolved +// endpoint in the structured log. The caller is responsible for restricting +// the method to GET/HEAD before calling. +func (a *Router) proxyBlobObject(w http.ResponseWriter, r *http.Request, account, logLabel string) { // Use the escaped path so encoded blob keys (%20, %23, %2F, ...) reach the // origin intact rather than being decoded by net/http. originURL := a.blobOriginURL(account, r.URL.EscapedPath(), r.URL.RawQuery) @@ -109,7 +121,7 @@ func (a *Router) ServeCDNContent(w http.ResponseWriter, r *http.Request) { resp, err := cdnOriginClient.Do(proxyReq) if err != nil { - log.Error().Err(err).Str("endpoint", name).Str("origin", originURL). + log.Error().Err(err).Str("endpoint", logLabel).Str("origin", originURL). Msg("cdn origin fetch failed") writeAzureError(w, http.StatusBadGateway, "OriginUnreachable", fmt.Sprintf("could not reach CDN origin: %s", err)) @@ -131,7 +143,7 @@ func (a *Router) ServeCDNContent(w http.ResponseWriter, r *http.Request) { // the (emulated) edge rather than hitting the origin directly. w.Header().Set("X-Cache", "CONFIG_NOCACHE") - log.Info().Str("endpoint", name).Str("account", account). + log.Info().Str("endpoint", logLabel).Str("account", account). Str("path", r.URL.Path).Int("status", resp.StatusCode). Msg("cdn content served") diff --git a/internal/arm/cdn_frontdoor.go b/internal/arm/cdn_frontdoor.go new file mode 100644 index 0000000..bd595c8 --- /dev/null +++ b/internal/arm/cdn_frontdoor.go @@ -0,0 +1,401 @@ +package arm + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + + "github.com/zerodeth/azemu/internal/store" +) + +// Azure Front Door (Standard/Premium) shares the Microsoft.Cdn/profiles root +// with classic CDN; the profile handler in cdn.go already accepts any SKU +// (Standard_AzureFrontDoor, Premium_AzureFrontDoor), so only the AFD-specific +// child graph lives here: afdEndpoints, originGroups, origins, and routes. SKU +// is a no-op in emulation; what matters is the deterministic *.azurefd.net host +// the endpoint advertises and the route -> origin-group -> origin chain the +// data plane (cdn_frontdoor_dataplane.go) walks to find the Blob origin. +// +// The azurerm provider pins the child types to the track1 cdn/2021-06-01 SDK, +// whose long-running-operation future is satisfied by a terminal 200/201 on +// create with no async header (azemu writes synchronously). DELETE reuses the +// shared acceptAsyncDelete 202 path. azemu's RequireAPIVersion middleware is +// value-agnostic, so the 2021-06-01 vs 2024-02-01 split does not matter here. +const ( + afdEndpointTypeString = "Microsoft.Cdn/profiles/afdEndpoints" + afdOriginGroupTypeString = "Microsoft.Cdn/profiles/originGroups" + afdOriginTypeString = "Microsoft.Cdn/profiles/originGroups/origins" + afdRouteTypeString = "Microsoft.Cdn/profiles/afdEndpoints/routes" +) + +// afdHostSuffix is the Front Door endpoint host suffix. afdEndpointID and the +// host-name generator below keep it in one place so the control plane and the +// data plane agree on the host the endpoint advertises. +const afdHostSuffix = ".azurefd.net" + +func afdEndpointID(subID, rgName, profileName, endpointName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/afdEndpoints/%s", + subID, rgName, profileName, endpointName, + ) +} + +func afdOriginGroupID(subID, rgName, profileName, groupName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/originGroups/%s", + subID, rgName, profileName, groupName, + ) +} + +func afdOriginID(subID, rgName, profileName, groupName, originName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/originGroups/%s/origins/%s", + subID, rgName, profileName, groupName, originName, + ) +} + +func afdRouteID(subID, rgName, profileName, endpointName, routeName string) string { + return fmt.Sprintf( + "/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Cdn/profiles/%s/afdEndpoints/%s/routes/%s", + subID, rgName, profileName, endpointName, routeName, + ) +} + +// afdGeneratedHostName returns the deterministic host the data plane muxes on. +// Real Azure mints "{name}-{hash}.z01.azurefd.net"; azemu uses the stable +// "{name}.azurefd.net" so the same value the provider reads from the response +// (azurerm_cdn_frontdoor_endpoint.X.host_name) is what the host-mux resolves. +func afdGeneratedHostName(endpointName string) string { + return endpointName + afdHostSuffix +} + +// afdChildBody is the PUT payload shared by every AFD child resource. Only the +// afdEndpoint carries a location ("global"); originGroups, origins, and routes +// have no location field. Properties are stored verbatim so the provider's +// round-trip reads (originGroup.id, linkToDefaultDomain, healthProbeSettings, +// ...) are satisfied with the exact JSON the provider sent. +type afdChildBody struct { + Location string `json:"location"` + Tags map[string]string `json:"tags"` + Properties map[string]interface{} `json:"properties"` +} + +// --- afdEndpoints (Microsoft.Cdn/profiles/afdEndpoints) --- + +func (a *Router) putAFDEndpoint(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + rgName := chi.URLParam(r, "resourceGroupName") + profileName := chi.URLParam(r, "profileName") + endpointName := chi.URLParam(r, "endpointName") + + if _, ok := a.store.Get(cdnProfileID(subID, rgName, profileName)); !ok { + writeAzureError(w, http.StatusNotFound, "ParentResourceNotFound", + fmt.Sprintf("The parent resource 'Microsoft.Cdn/profiles/%s' was not found.", profileName)) + return + } + + body, ok := decodeAFDChildBody(w, r) + if !ok { + return + } + body.Properties["provisioningState"] = "Succeeded" + // hostName is a read-only property the provider copies into host_name. Azure + // generates it; azemu computes the stable *.azurefd.net form the data plane + // resolves. + body.Properties["hostName"] = afdGeneratedHostName(endpointName) + + location := strings.ToLower(body.Location) + if location == "" { + location = "global" + } + + id := afdEndpointID(subID, rgName, profileName, endpointName) + a.upsertAFDChild(w, id, endpointName, afdEndpointTypeString, location, body, "AFD endpoint") +} + +func (a *Router) getAFDEndpoint(w http.ResponseWriter, r *http.Request) { + id := afdEndpointID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName")) + a.getAFDChild(w, id, "afdEndpoints", chi.URLParam(r, "endpointName")) +} + +func (a *Router) headAFDEndpoint(w http.ResponseWriter, r *http.Request) { + id := afdEndpointID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName")) + a.headAFDChild(w, id) +} + +func (a *Router) deleteAFDEndpoint(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + id := afdEndpointID(subID, chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName")) + a.deleteAFDChild(w, r, id, subID, "afdEndpoints", chi.URLParam(r, "endpointName")) +} + +func (a *Router) listAFDEndpoints(w http.ResponseWriter, r *http.Request) { + prefix := afdEndpointID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), "") + a.writeAFDChildList(w, prefix, afdEndpointTypeString) +} + +// --- originGroups (Microsoft.Cdn/profiles/originGroups) --- + +func (a *Router) putAFDOriginGroup(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + rgName := chi.URLParam(r, "resourceGroupName") + profileName := chi.URLParam(r, "profileName") + groupName := chi.URLParam(r, "originGroupName") + + if _, ok := a.store.Get(cdnProfileID(subID, rgName, profileName)); !ok { + writeAzureError(w, http.StatusNotFound, "ParentResourceNotFound", + fmt.Sprintf("The parent resource 'Microsoft.Cdn/profiles/%s' was not found.", profileName)) + return + } + + body, ok := decodeAFDChildBody(w, r) + if !ok { + return + } + body.Properties["provisioningState"] = "Succeeded" + + id := afdOriginGroupID(subID, rgName, profileName, groupName) + a.upsertAFDChild(w, id, groupName, afdOriginGroupTypeString, "", body, "AFD origin group") +} + +func (a *Router) getAFDOriginGroup(w http.ResponseWriter, r *http.Request) { + id := afdOriginGroupID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName")) + a.getAFDChild(w, id, "originGroups", chi.URLParam(r, "originGroupName")) +} + +func (a *Router) headAFDOriginGroup(w http.ResponseWriter, r *http.Request) { + id := afdOriginGroupID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName")) + a.headAFDChild(w, id) +} + +func (a *Router) deleteAFDOriginGroup(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + id := afdOriginGroupID(subID, chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName")) + a.deleteAFDChild(w, r, id, subID, "originGroups", chi.URLParam(r, "originGroupName")) +} + +func (a *Router) listAFDOriginGroups(w http.ResponseWriter, r *http.Request) { + prefix := afdOriginGroupID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), "") + a.writeAFDChildList(w, prefix, afdOriginGroupTypeString) +} + +// --- origins (Microsoft.Cdn/profiles/originGroups/origins) --- + +func (a *Router) putAFDOrigin(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + rgName := chi.URLParam(r, "resourceGroupName") + profileName := chi.URLParam(r, "profileName") + groupName := chi.URLParam(r, "originGroupName") + originName := chi.URLParam(r, "originName") + + if _, ok := a.store.Get(afdOriginGroupID(subID, rgName, profileName, groupName)); !ok { + writeAzureError(w, http.StatusNotFound, "ParentResourceNotFound", + fmt.Sprintf("The parent resource 'Microsoft.Cdn/profiles/%s/originGroups/%s' was not found.", profileName, groupName)) + return + } + + body, ok := decodeAFDChildBody(w, r) + if !ok { + return + } + body.Properties["provisioningState"] = "Succeeded" + + id := afdOriginID(subID, rgName, profileName, groupName, originName) + a.upsertAFDChild(w, id, originName, afdOriginTypeString, "", body, "AFD origin") +} + +func (a *Router) getAFDOrigin(w http.ResponseWriter, r *http.Request) { + id := afdOriginID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName"), chi.URLParam(r, "originName")) + a.getAFDChild(w, id, "origins", chi.URLParam(r, "originName")) +} + +func (a *Router) headAFDOrigin(w http.ResponseWriter, r *http.Request) { + id := afdOriginID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName"), chi.URLParam(r, "originName")) + a.headAFDChild(w, id) +} + +func (a *Router) deleteAFDOrigin(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + id := afdOriginID(subID, chi.URLParam(r, "resourceGroupName"), chi.URLParam(r, "profileName"), + chi.URLParam(r, "originGroupName"), chi.URLParam(r, "originName")) + a.deleteAFDChild(w, r, id, subID, "origins", chi.URLParam(r, "originName")) +} + +func (a *Router) listAFDOrigins(w http.ResponseWriter, r *http.Request) { + prefix := afdOriginID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "originGroupName"), "") + a.writeAFDChildList(w, prefix, afdOriginTypeString) +} + +// --- routes (Microsoft.Cdn/profiles/afdEndpoints/routes) --- + +func (a *Router) putAFDRoute(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + rgName := chi.URLParam(r, "resourceGroupName") + profileName := chi.URLParam(r, "profileName") + endpointName := chi.URLParam(r, "endpointName") + routeName := chi.URLParam(r, "routeName") + + if _, ok := a.store.Get(afdEndpointID(subID, rgName, profileName, endpointName)); !ok { + writeAzureError(w, http.StatusNotFound, "ParentResourceNotFound", + fmt.Sprintf("The parent resource 'Microsoft.Cdn/profiles/%s/afdEndpoints/%s' was not found.", profileName, endpointName)) + return + } + + body, ok := decodeAFDChildBody(w, r) + if !ok { + return + } + body.Properties["provisioningState"] = "Succeeded" + + id := afdRouteID(subID, rgName, profileName, endpointName, routeName) + a.upsertAFDChild(w, id, routeName, afdRouteTypeString, "", body, "AFD route") +} + +func (a *Router) getAFDRoute(w http.ResponseWriter, r *http.Request) { + id := afdRouteID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName"), chi.URLParam(r, "routeName")) + a.getAFDChild(w, id, "routes", chi.URLParam(r, "routeName")) +} + +func (a *Router) headAFDRoute(w http.ResponseWriter, r *http.Request) { + id := afdRouteID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName"), chi.URLParam(r, "routeName")) + a.headAFDChild(w, id) +} + +func (a *Router) deleteAFDRoute(w http.ResponseWriter, r *http.Request) { + subID := chi.URLParam(r, "subscriptionID") + id := afdRouteID(subID, chi.URLParam(r, "resourceGroupName"), chi.URLParam(r, "profileName"), + chi.URLParam(r, "endpointName"), chi.URLParam(r, "routeName")) + a.deleteAFDChild(w, r, id, subID, "routes", chi.URLParam(r, "routeName")) +} + +func (a *Router) listAFDRoutes(w http.ResponseWriter, r *http.Request) { + prefix := afdRouteID(chi.URLParam(r, "subscriptionID"), chi.URLParam(r, "resourceGroupName"), + chi.URLParam(r, "profileName"), chi.URLParam(r, "endpointName"), "") + a.writeAFDChildList(w, prefix, afdRouteTypeString) +} + +// --- shared AFD child helpers --- + +func decodeAFDChildBody(w http.ResponseWriter, r *http.Request) (afdChildBody, bool) { + var body afdChildBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeAzureError(w, http.StatusBadRequest, "InvalidRequestContent", err.Error()) + return body, false + } + if body.Properties == nil { + body.Properties = map[string]interface{}{} + } + return body, true +} + +// upsertAFDChild stores an AFD child resource and writes the ARM envelope, +// returning 201 on first write and 200 on update, mirroring putCDNEndpoint. +func (a *Router) upsertAFDChild(w http.ResponseWriter, id, name, typeStr, location string, body afdChildBody, logLabel string) { + res := &store.Resource{ + ID: id, + Name: name, + Type: typeStr, + Location: location, + Tags: normaliseTags(body.Tags), + Properties: body.Properties, + } + + _, exists := a.store.Get(id) + if err := a.store.Put(id, res); err != nil { + writeAzureError(w, http.StatusInternalServerError, "InternalServerError", + fmt.Sprintf("put %s %q: %s", logLabel, name, err)) + return + } + + status := http.StatusCreated + if exists { + status = http.StatusOK + } + log.Info().Str("resource_id", id).Bool("existed", exists).Msgf("%s upsert", logLabel) + writeJSON(w, status, afdChildResponse(res)) +} + +func (a *Router) getAFDChild(w http.ResponseWriter, id, segment, name string) { + res, ok := a.store.Get(id) + if !ok { + writeAzureError(w, http.StatusNotFound, "ResourceNotFound", + fmt.Sprintf("The Resource 'Microsoft.Cdn/profiles/.../%s/%s' was not found.", segment, name)) + return + } + writeJSON(w, http.StatusOK, afdChildResponse(res)) +} + +func (a *Router) headAFDChild(w http.ResponseWriter, id string) { + if _, ok := a.store.Get(id); !ok { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *Router) deleteAFDChild(w http.ResponseWriter, r *http.Request, id, subID, segment, name string) { + if !a.store.Delete(id) { + writeAzureError(w, http.StatusNotFound, "ResourceNotFound", + fmt.Sprintf("The Resource 'Microsoft.Cdn/profiles/.../%s/%s' was not found.", segment, name)) + return + } + log.Info().Str("resource_id", id).Msgf("AFD %s deleted", segment) + a.acceptAsyncDelete(w, r, subID) +} + +func (a *Router) writeAFDChildList(w http.ResponseWriter, prefix, typeStr string) { + items := []map[string]interface{}{} + for _, res := range a.store.List(prefix) { + if res.Type != typeStr { + continue + } + items = append(items, afdChildResponse(res)) + } + writeJSON(w, http.StatusOK, map[string]interface{}{"value": items}) +} + +// afdChildResponse builds the ARM envelope for an AFD child resource. The +// stored properties are echoed verbatim (with a terminal provisioningState) so +// the provider's reads of originGroup.id, hostName, linkToDefaultDomain, the +// health-probe block, and the rest round-trip exactly as sent. location is +// emitted only when set (the afdEndpoint is "global"; the other three child +// types have no location). +func afdChildResponse(res *store.Resource) map[string]interface{} { + props := map[string]interface{}{"provisioningState": "Succeeded"} + for k, val := range res.Properties { + if k == "provisioningState" { + continue + } + props[k] = val + } + out := map[string]interface{}{ + "id": res.ID, + "name": res.Name, + "type": res.Type, + "properties": props, + } + if res.Location != "" { + out["location"] = res.Location + } + if res.Tags != nil { + out["tags"] = res.Tags + } + return out +} diff --git a/internal/arm/cdn_frontdoor_dataplane.go b/internal/arm/cdn_frontdoor_dataplane.go new file mode 100644 index 0000000..d4a1f8c --- /dev/null +++ b/internal/arm/cdn_frontdoor_dataplane.go @@ -0,0 +1,188 @@ +package arm + +import ( + "fmt" + "net" + "net/http" + "strings" + + "github.com/zerodeth/azemu/internal/store" +) + +// The Front Door content data plane answers requests to a generated +// "{endpoint}.azurefd.net" host. Unlike classic CDN, where the endpoint carries +// its origin inline, Front Door spreads the origin across a resource graph: +// afdEndpoint -> route -> originGroup -> origin. ServeAFDContent walks that +// chain to find the Blob origin, then reuses the shared blob proxy. The two +// data planes coexist (the host-mux in serve.go dispatches by suffix), so users +// pinned to classic CDN (azurerm < 4.35, *.azureedge.net) keep working while +// Front Door scenarios (azurerm >= 4.35, *.azurefd.net) run side by side. + +// afdEndpointNameFromHost returns the endpoint name encoded in a +// "{endpoint}.azurefd.net" host, mirroring the hostName azemu generates on +// afdEndpoint create. Returns false for any other host shape (an ARM host, +// plain localhost, a classic *.azureedge.net host, or a multi-label name). +func afdEndpointNameFromHost(host string) (string, bool) { + hostname := host + if h, _, err := net.SplitHostPort(host); err == nil { + hostname = h + } + // DNS hostnames are case-insensitive; normalise before the suffix check. + hostname = strings.ToLower(hostname) + if !strings.HasSuffix(hostname, afdHostSuffix) { + return "", false + } + name := strings.TrimSuffix(hostname, afdHostSuffix) + if name == "" || strings.Contains(name, ".") { + return "", false + } + return name, true +} + +// IsAFDContentHost reports whether a request Host targets the Front Door content +// data plane. The serve.go host-mux uses it to route between the AFD proxy and +// the ARM control plane, alongside the classic IsCDNContentHost check. +func IsAFDContentHost(host string) bool { + _, ok := afdEndpointNameFromHost(host) + return ok +} + +// ServeAFDContent reverse-proxies a Front Door endpoint content request to its +// Blob origin (Azurite) and streams the response back. It resolves the origin +// by walking afdEndpoint -> route -> originGroup -> origin, then hands off to +// the shared blob proxy so the origin's Content-Type and Cache-Control reach +// the client unchanged (essential for OTA: the multipart manifest must keep its +// boundary and short TTL). Only GET and HEAD are served, as on a real endpoint. +func (a *Router) ServeAFDContent(w http.ResponseWriter, r *http.Request) { + name, ok := afdEndpointNameFromHost(r.Host) + if !ok { + writeAzureError(w, http.StatusNotFound, "ResourceNotFound", + "Unrecognised Front Door endpoint host.") + return + } + + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + writeAzureError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", + "Front Door endpoints serve GET and HEAD only.") + return + } + + endpoint, ok := a.findAFDEndpoint(name) + if !ok { + writeAzureError(w, http.StatusNotFound, "ResourceNotFound", + fmt.Sprintf("Front Door endpoint %q was not found.", name)) + return + } + + account, ok := a.resolveAFDOriginAccount(endpoint) + if !ok { + writeAzureError(w, http.StatusBadGateway, "OriginUnresolved", + "Front Door endpoint does not resolve to a recognised Azure Blob origin.") + return + } + + a.proxyBlobObject(w, r, account, name) +} + +// findAFDEndpoint returns the stored afdEndpoint resource whose name matches, +// scanning the store for the afdEndpoint type. Endpoint names are unique enough +// within a local emulator, the same assumption the classic CDN and Key Vault +// host resolvers make. +func (a *Router) findAFDEndpoint(name string) (*store.Resource, bool) { + for _, res := range a.store.List("/subscriptions/") { + if res.Type == afdEndpointTypeString && res.Name == name { + return res, true + } + } + return nil, false +} + +// resolveAFDOriginAccount walks the Front Door resource graph from an endpoint +// to its backing Blob storage account: it finds a route under the endpoint, +// follows the route's originGroup reference, picks the preferred origin in that +// group, and parses the storage account from the origin's hostName. ARM IDs are +// compared case-insensitively because the originGroup reference the provider +// writes into the route may differ in casing from azemu's stored key. +func (a *Router) resolveAFDOriginAccount(endpoint *store.Resource) (string, bool) { + originGroupID, ok := a.routeOriginGroupID(endpoint.ID) + if !ok { + return "", false + } + origin, ok := a.preferredOrigin(originGroupID) + if !ok { + return "", false + } + host, _ := origin.Properties["hostName"].(string) + return blobAccountFromHost(host) +} + +// routeOriginGroupID returns the originGroup ARM ID referenced by the first +// route under the given afdEndpoint. A minimal Front Door config has a single +// route with link_to_default_domain enabled; if a scenario ever adds multiple +// routes, the first one with an origin-group reference wins (pattern-priority +// selection is out of scope until a scenario needs it). +func (a *Router) routeOriginGroupID(endpointID string) (string, bool) { + for _, res := range a.store.List(endpointID + "/") { + if res.Type != afdRouteTypeString { + continue + } + og, ok := res.Properties["originGroup"].(map[string]interface{}) + if !ok { + continue + } + if id, ok := og["id"].(string); ok && id != "" { + return id, true + } + } + return "", false +} + +// preferredOrigin returns the highest-precedence origin in the referenced +// origin group: lowest priority value first, then highest weight. The group is +// matched case-insensitively against stored origin IDs. In the emulated single- +// origin case this just returns that origin; the ordering matters only when a +// group lists several. +func (a *Router) preferredOrigin(originGroupID string) (*store.Resource, bool) { + wantPrefix := strings.ToLower(originGroupID) + "/origins/" + var best *store.Resource + var bestPriority, bestWeight float64 + for _, res := range a.store.List("/subscriptions/") { + if res.Type != afdOriginTypeString { + continue + } + if !strings.HasPrefix(strings.ToLower(res.ID), wantPrefix) { + continue + } + priority := floatProp(res.Properties, "priority", 1) + weight := floatProp(res.Properties, "weight", 1000) + if best == nil || priority < bestPriority || (priority == bestPriority && weight > bestWeight) { + best, bestPriority, bestWeight = res, priority, weight + } + } + return best, best != nil +} + +// floatProp reads a numeric property as float64, falling back to def when the +// key is absent or not a JSON number. +func floatProp(props map[string]interface{}, key string, def float64) float64 { + if v, ok := props[key].(float64); ok { + return v + } + return def +} + +// blobAccountFromHost extracts the storage account label from an Azure Blob +// origin host ("{account}.blob.core.windows.net"). azemu serves blobs path- +// style from Azurite, so only the account label is needed to build the origin +// URL. Mirrors cdnOriginAccount's parse for the classic CDN data plane. +func blobAccountFromHost(host string) (string, bool) { + if host == "" { + return "", false + } + labels := strings.Split(host, ".") + if len(labels) >= 2 && labels[0] != "" && labels[1] == "blob" { + return labels[0], true + } + return "", false +} diff --git a/internal/arm/cdn_frontdoor_dataplane_test.go b/internal/arm/cdn_frontdoor_dataplane_test.go new file mode 100644 index 0000000..f653fcf --- /dev/null +++ b/internal/arm/cdn_frontdoor_dataplane_test.go @@ -0,0 +1,197 @@ +package arm + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/zerodeth/azemu/internal/store" +) + +func TestAFDEndpointNameFromHost(t *testing.T) { + cases := []struct { + host string + wantName string + wantOK bool + }{ + {"fdedge.azurefd.net", "fdedge", true}, + {"fdedge.azurefd.net:4566", "fdedge", true}, + {"my-endpoint-1.azurefd.net", "my-endpoint-1", true}, + {"localhost", "", false}, + {"vault1.vault.localhost", "", false}, + {"otacdn.azureedge.net", "", false}, // classic CDN host is not a Front Door host + {"azurefd.net", "", false}, + {".azurefd.net", "", false}, + {"a.b.azurefd.net", "", false}, // multi-label endpoint name is not valid + } + for _, c := range cases { + gotName, gotOK := afdEndpointNameFromHost(c.host) + if gotName != c.wantName || gotOK != c.wantOK { + t.Errorf("afdEndpointNameFromHost(%q) = (%q, %v), want (%q, %v)", + c.host, gotName, gotOK, c.wantName, c.wantOK) + } + } +} + +func TestBlobAccountFromHost(t *testing.T) { + cases := []struct { + host string + wantAcct string + wantOK bool + }{ + {"otasa.blob.core.windows.net", "otasa", true}, + {"otasa.dfs.core.windows.net", "", false}, + {"", "", false}, + {"notblob", "", false}, + } + for _, c := range cases { + got, ok := blobAccountFromHost(c.host) + if got != c.wantAcct || ok != c.wantOK { + t.Errorf("blobAccountFromHost(%q) = (%q, %v), want (%q, %v)", + c.host, got, ok, c.wantAcct, c.wantOK) + } + } +} + +// seedAFDGraph stores a full Front Door resource graph (endpoint -> route -> +// originGroup -> origin) whose origin points at the given storage account, and +// returns a Router wired to the given Azurite origin base. The IDs use the same +// builders the handlers use so the data-plane resolution (prefix + originGroup +// reference) matches exactly. +func seedAFDGraph(t *testing.T, originBase, endpointName, account string) *Router { + t.Helper() + s := store.NewMemoryStore() + a := NewRouter(s, originBase, "https://kv-test", "redis://redis-test:6379") + + const sub, rg, profile, group, origin, route = "sub1", "rg1", "fd1", "og1", "o1", "r1" + ogID := afdOriginGroupID(sub, rg, profile, group) + + put := func(id, name, typ string, props map[string]interface{}) { + if err := s.Put(id, &store.Resource{ID: id, Name: name, Type: typ, Properties: props}); err != nil { + t.Fatalf("seed %s: %v", id, err) + } + } + put(afdEndpointID(sub, rg, profile, endpointName), endpointName, afdEndpointTypeString, + map[string]interface{}{"hostName": endpointName + ".azurefd.net"}) + put(ogID, group, afdOriginGroupTypeString, map[string]interface{}{}) + put(afdOriginID(sub, rg, profile, group, origin), origin, afdOriginTypeString, + map[string]interface{}{ + "hostName": account + ".blob.core.windows.net", + "priority": float64(1), + "weight": float64(1000), + }) + put(afdRouteID(sub, rg, profile, endpointName, route), route, afdRouteTypeString, + map[string]interface{}{"originGroup": map[string]interface{}{"id": ogID}}) + return a +} + +func TestServeAFDContent_passthrough(t *testing.T) { + var gotPath string + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "multipart/mixed; boundary=abc") + w.Header().Set("Cache-Control", "max-age=30") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "manifest-bytes") + })) + defer origin.Close() + + a := seedAFDGraph(t, origin.URL, "fdedge", "otasa") + + req := httptest.NewRequest(http.MethodGet, "http://fdedge.azurefd.net/ota/1.0.0/manifest.json", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + resp := rec.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if want := "/otasa/ota/1.0.0/manifest.json"; gotPath != want { + t.Errorf("origin path = %q, want %q", gotPath, want) + } + if ct := resp.Header.Get("Content-Type"); ct != "multipart/mixed; boundary=abc" { + t.Errorf("Content-Type = %q, want multipart passthrough", ct) + } + if cc := resp.Header.Get("Cache-Control"); cc != "max-age=30" { + t.Errorf("Cache-Control = %q, want max-age=30 passthrough", cc) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "manifest-bytes" { + t.Errorf("body = %q, want origin bytes", string(body)) + } +} + +func TestServeAFDContent_head_noBody(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "should-not-be-forwarded-on-head") + })) + defer origin.Close() + + a := seedAFDGraph(t, origin.URL, "fdedge", "otasa") + + req := httptest.NewRequest(http.MethodHead, "http://fdedge.azurefd.net/ota/asset.png", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + resp := rec.Result() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if cc := resp.Header.Get("Cache-Control"); cc != "public, max-age=31536000, immutable" { + t.Errorf("Cache-Control = %q, want immutable passthrough", cc) + } + body, _ := io.ReadAll(resp.Body) + if len(body) != 0 { + t.Errorf("HEAD returned %d body bytes, want empty", len(body)) + } +} + +func TestServeAFDContent_unknownEndpoint_404(t *testing.T) { + s := store.NewMemoryStore() + a := NewRouter(s, "http://azurite-test:10000", "https://kv-test", "redis://redis-test:6379") + + req := httptest.NewRequest(http.MethodGet, "http://ghost.azurefd.net/x", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + if rec.Result().StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404 for unknown endpoint", rec.Result().StatusCode) + } +} + +func TestServeAFDContent_unresolvedGraph_502(t *testing.T) { + // Endpoint exists but has no route/origin graph, so the origin cannot be + // resolved: the proxy reports a bad gateway rather than a 404. + s := store.NewMemoryStore() + a := NewRouter(s, "http://azurite-test:10000", "https://kv-test", "redis://redis-test:6379") + id := afdEndpointID("sub1", "rg1", "fd1", "fdedge") + if err := s.Put(id, &store.Resource{ + ID: id, Name: "fdedge", Type: afdEndpointTypeString, + Properties: map[string]interface{}{"hostName": "fdedge.azurefd.net"}, + }); err != nil { + t.Fatalf("seed endpoint: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://fdedge.azurefd.net/x", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + if rec.Result().StatusCode != http.StatusBadGateway { + t.Errorf("status = %d, want 502 for unresolved origin graph", rec.Result().StatusCode) + } +} + +func TestServeAFDContent_methodNotAllowed(t *testing.T) { + a := seedAFDGraph(t, "http://azurite-test:10000", "fdedge", "otasa") + + req := httptest.NewRequest(http.MethodPost, "http://fdedge.azurefd.net/x", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + if rec.Result().StatusCode != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Result().StatusCode) + } +} diff --git a/internal/arm/cdn_frontdoor_test.go b/internal/arm/cdn_frontdoor_test.go new file mode 100644 index 0000000..3e5c21a --- /dev/null +++ b/internal/arm/cdn_frontdoor_test.go @@ -0,0 +1,266 @@ +package arm + +import ( + "fmt" + "net/http" + "testing" +) + +// --- URL + body helpers --- + +func afdEndpointURL(srvURL, sub, rg, profile, endpoint string) string { + return fmt.Sprintf( + "%s/subscriptions/%s/resourcegroups/%s/providers/microsoft.cdn/profiles/%s/afdendpoints/%s", + srvURL, sub, rg, profile, endpoint, + ) +} + +func afdOriginGroupURL(srvURL, sub, rg, profile, group string) string { + return fmt.Sprintf( + "%s/subscriptions/%s/resourcegroups/%s/providers/microsoft.cdn/profiles/%s/origingroups/%s", + srvURL, sub, rg, profile, group, + ) +} + +func afdOriginURL(srvURL, sub, rg, profile, group, origin string) string { + return fmt.Sprintf( + "%s/subscriptions/%s/resourcegroups/%s/providers/microsoft.cdn/profiles/%s/origingroups/%s/origins/%s", + srvURL, sub, rg, profile, group, origin, + ) +} + +func afdRouteURL(srvURL, sub, rg, profile, endpoint, route string) string { + return fmt.Sprintf( + "%s/subscriptions/%s/resourcegroups/%s/providers/microsoft.cdn/profiles/%s/afdendpoints/%s/routes/%s", + srvURL, sub, rg, profile, endpoint, route, + ) +} + +const afdProfileBody = `{ + "location": "global", + "sku": {"name": "Standard_AzureFrontDoor"}, + "properties": {"originResponseTimeoutSeconds": 120} +}` + +const afdEndpointBody = `{ + "location": "global", + "properties": {"enabledState": "Enabled"} +}` + +const afdOriginGroupBody = `{ + "properties": { + "loadBalancingSettings": {"sampleSize": 4, "successfulSamplesRequired": 3, "additionalLatencyInMilliseconds": 50}, + "healthProbeSettings": {"probePath": "/", "probeRequestType": "HEAD", "probeProtocol": "Http", "probeIntervalInSeconds": 100} + } +}` + +const afdOriginBody = `{ + "properties": { + "hostName": "otasa.blob.core.windows.net", + "httpPort": 80, + "httpsPort": 443, + "originHostHeader": "otasa.blob.core.windows.net", + "priority": 1, + "weight": 1000, + "enabledState": "Enabled", + "enforceCertificateNameCheck": true + } +}` + +// seedAFDProfile creates the parent Microsoft.Cdn/profiles resource every AFD +// child hangs off, returning the server URL for convenience. +func seedAFDProfile(t *testing.T, srv string, sub, rg, profile string) { + t.Helper() + resp := httpPut(t, cdnProfileURL(srv, sub, rg, profile), afdProfileBody) + assertStatus(t, resp, http.StatusCreated) + resp.Body.Close() +} + +func afdRouteBody(originGroupID string) string { + return fmt.Sprintf(`{ + "properties": { + "originGroup": {"id": %q}, + "patternsToMatch": ["/*"], + "forwardingProtocol": "MatchRequest", + "linkToDefaultDomain": "Enabled", + "httpsRedirect": "Disabled", + "supportedProtocols": ["Http", "Https"] + } +}`, originGroupID) +} + +// --- afdEndpoint --- + +func TestAFDEndpoint_PUT_Creates_Returns201(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody) + assertStatus(t, resp, http.StatusCreated) + resp.Body.Close() +} + +func TestAFDEndpoint_PUT_HostNameGenerated(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "myedge"), afdEndpointBody) + body := decodeJSON(t, resp) + props := body["properties"].(map[string]interface{}) + if got := props["hostName"]; got != "myedge.azurefd.net" { + t.Errorf("hostName = %v, want myedge.azurefd.net", got) + } +} + +func TestAFDEndpoint_PUT_ParentMissing_Returns404(t *testing.T) { + srv := newTestServer(t).URL + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "ghost", "ep1"), afdEndpointBody) + assertStatus(t, resp, http.StatusNotFound) + resp.Body.Close() +} + +func TestAFDEndpoint_PUT_Idempotent_Returns200(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody) + assertStatus(t, resp, http.StatusOK) + resp.Body.Close() +} + +func TestAFDEndpoint_GET_ReturnsStored(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + resp := httpGet(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")) + body := decodeJSON(t, resp) + if body["type"] != afdEndpointTypeString { + t.Errorf("type = %v, want %s", body["type"], afdEndpointTypeString) + } + if body["location"] != "global" { + t.Errorf("location = %v, want global", body["location"]) + } +} + +func TestAFDEndpoint_HEAD_And_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + assertStatus(t, httpHead(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusNoContent) + assertStatus(t, httpHead(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ghost")), http.StatusNotFound) +} + +func TestAFDEndpoint_DELETE_Then_GET_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + assertStatus(t, httpDelete(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusNotFound) +} + +func TestAFDEndpoint_LIST_ValueWrapper(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + resp := httpGet(t, fmt.Sprintf( + "%s/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.cdn/profiles/fd1/afdendpoints", srv)) + body := decodeJSON(t, resp) + items, ok := body["value"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("value = %v, want 1 endpoint", body["value"]) + } +} + +// --- originGroup --- + +func TestAFDOriginGroup_PUT_Creates_NoLocation(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody) + assertStatus(t, resp, http.StatusCreated) + body := decodeJSON(t, resp) + if _, hasLoc := body["location"]; hasLoc { + t.Errorf("originGroup response should omit location, got %v", body["location"]) + } + props := body["properties"].(map[string]interface{}) + hp := props["healthProbeSettings"].(map[string]interface{}) + if hp["probeProtocol"] != "Http" { + t.Errorf("probeProtocol = %v, want Http (echoed verbatim)", hp["probeProtocol"]) + } +} + +func TestAFDOriginGroup_PUT_ParentMissing_Returns404(t *testing.T) { + srv := newTestServer(t).URL + resp := httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "ghost", "og1"), afdOriginGroupBody) + assertStatus(t, resp, http.StatusNotFound) + resp.Body.Close() +} + +// --- origin --- + +func TestAFDOrigin_PUT_Creates_PropsEchoed(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + resp := httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody) + assertStatus(t, resp, http.StatusCreated) + props := decodeJSON(t, resp)["properties"].(map[string]interface{}) + if props["hostName"] != "otasa.blob.core.windows.net" { + t.Errorf("hostName = %v, want otasa.blob.core.windows.net", props["hostName"]) + } +} + +func TestAFDOrigin_PUT_ParentGroupMissing_Returns404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "ghost", "o1"), afdOriginBody) + assertStatus(t, resp, http.StatusNotFound) + resp.Body.Close() +} + +// --- route --- + +func TestAFDRoute_PUT_Creates_OriginGroupEchoed(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + ogID := afdOriginGroupID("sub1", "rg1", "fd1", "og1") + resp := httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), afdRouteBody(ogID)) + assertStatus(t, resp, http.StatusCreated) + props := decodeJSON(t, resp)["properties"].(map[string]interface{}) + og := props["originGroup"].(map[string]interface{}) + if og["id"] != ogID { + t.Errorf("originGroup.id = %v, want %s", og["id"], ogID) + } + if props["linkToDefaultDomain"] != "Enabled" { + t.Errorf("linkToDefaultDomain = %v, want Enabled", props["linkToDefaultDomain"]) + } +} + +func TestAFDRoute_PUT_ParentEndpointMissing_Returns404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ghost", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))) + assertStatus(t, resp, http.StatusNotFound) + resp.Body.Close() +} + +// --- cascade --- + +func TestAFDProfile_DELETE_CascadesChildren(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + + assertStatus(t, httpDelete(t, cdnProfileURL(srv, "sub1", "rg1", "fd1")), http.StatusAccepted) + + // Every child must be gone after the parent profile cascade-deletes. + assertStatus(t, httpGet(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusNotFound) + assertStatus(t, httpGet(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusNotFound) + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusNotFound) + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusNotFound) +} diff --git a/internal/arm/router.go b/internal/arm/router.go index 652f384..aeebabd 100644 --- a/internal/arm/router.go +++ b/internal/arm/router.go @@ -246,6 +246,34 @@ func (a *Router) Routes(r chi.Router) { r.Delete("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/endpoints/{endpointName}", a.deleteCDNEndpoint) r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/endpoints", a.listCDNEndpoints) + // Front Door endpoints (Microsoft.Cdn/profiles/afdEndpoints) + r.Put("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}", a.putAFDEndpoint) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}", a.getAFDEndpoint) + r.Head("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}", a.headAFDEndpoint) + r.Delete("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}", a.deleteAFDEndpoint) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints", a.listAFDEndpoints) + + // Front Door routes (Microsoft.Cdn/profiles/afdEndpoints/routes) + r.Put("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}/routes/{routeName}", a.putAFDRoute) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}/routes/{routeName}", a.getAFDRoute) + r.Head("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}/routes/{routeName}", a.headAFDRoute) + r.Delete("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}/routes/{routeName}", a.deleteAFDRoute) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/afdendpoints/{endpointName}/routes", a.listAFDRoutes) + + // Front Door origin groups (Microsoft.Cdn/profiles/originGroups) + r.Put("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}", a.putAFDOriginGroup) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}", a.getAFDOriginGroup) + r.Head("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}", a.headAFDOriginGroup) + r.Delete("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}", a.deleteAFDOriginGroup) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups", a.listAFDOriginGroups) + + // Front Door origins (Microsoft.Cdn/profiles/originGroups/origins) + r.Put("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}/origins/{originName}", a.putAFDOrigin) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}/origins/{originName}", a.getAFDOrigin) + r.Head("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}/origins/{originName}", a.headAFDOrigin) + r.Delete("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}/origins/{originName}", a.deleteAFDOrigin) + r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.cdn/profiles/{profileName}/origingroups/{originGroupName}/origins", a.listAFDOrigins) + // User Assigned Identities (Microsoft.ManagedIdentity/userAssignedIdentities) r.Put("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.managedidentity/userassignedidentities/{identityName}/federatedidentitycredentials/{credentialName}", a.putFederatedIdentityCredential) r.Get("/{subscriptionID}/resourcegroups/{resourceGroupName}/providers/microsoft.managedidentity/userassignedidentities/{identityName}/federatedidentitycredentials/{credentialName}", a.getFederatedIdentityCredential) diff --git a/internal/middleware/pathcase.go b/internal/middleware/pathcase.go index b7b59fc..9b97093 100644 --- a/internal/middleware/pathcase.go +++ b/internal/middleware/pathcase.go @@ -58,6 +58,11 @@ var canonicalLiteralSegments = map[string]string{ "microsoft.cdn": "microsoft.cdn", "profiles": "profiles", "endpoints": "endpoints", + // Front Door (Standard/Premium) child segments under Microsoft.Cdn/profiles. + "afdendpoints": "afdendpoints", + "origingroups": "origingroups", + "origins": "origins", + "routes": "routes", "microsoft.cache": "microsoft.cache", "redis": "redis", // Phase 8 resource types diff --git a/website/docs/concepts/parity-matrix.md b/website/docs/concepts/parity-matrix.md index 1a7f778..88b8cf9 100644 --- a/website/docs/concepts/parity-matrix.md +++ b/website/docs/concepts/parity-matrix.md @@ -35,7 +35,8 @@ and should read Scaffold or Planned instead. | DNS Zones | Full | N/A | `azurerm_dns_zone`, `azurerm_dns_a_record`, `azurerm_dns_aaaa_record`, `azurerm_dns_cname_record`, `azurerm_dns_txt_record`, `azurerm_dns_mx_record`, `azurerm_dns_srv_record`, `azurerm_dns_ns_record` | Full (auto-SOA + auto-NS on zone create; A, AAAA, CNAME, TXT, MX, SRV, NS, SOA record sets as children; cascade delete) | [dns_test.go](../internal/arm/dns_test.go), [arm_test.go](../test/integration/arm_test.go) | | Storage Accounts | Full | Delegated to Azurite | `azurerm_storage_account`, `azurerm_storage_container` | Full (management plane; Azurite path-style endpoints; `listKeys` returns Azurite dev key; name uniqueness check; blob containers as child resources with cascade delete) | [storage_account_test.go](../internal/arm/storage_account_test.go), [storage_container_test.go](../internal/arm/storage_container_test.go), [arm_test.go](../test/integration/arm_test.go) | | Key Vault | Full | Full | `azurerm_key_vault`, `azurerm_key_vault_secret`, `azurerm_key_vault_key` | Full (management plane; `vaultUri` rewritten to azemu's per-vault host `https://{name}.vault.localhost[:port]/`; root-level `/keys` and `/secrets` routes resolve the vault from the Host header; SKU/soft-delete defaults; secrets with versioning, list, and cascade delete on vault destroy; RSA keys with versioning, import, RS256 sign verified against the returned public JWK, versionless sign via current pointer; no key export, wrap/unwrap, EC keys, or rotation policy; protected routes honour azemu-issued bearer tokens) | [keyvault_test.go](../internal/arm/keyvault_test.go), [keyvault_secret_test.go](../internal/arm/keyvault_secret_test.go), [keyvault_key_test.go](../internal/arm/keyvault_key_test.go), [arm_test.go](../test/integration/arm_test.go), [workload_identity_test.go](../test/integration/workload_identity_test.go) | -| CDN | Full | Full | `azurerm_cdn_profile`, `azurerm_cdn_endpoint` | Full (profile SKU at top level; endpoint `hostName` computed as `{name}.azureedge.net`; cascade delete; parent-existence check on endpoint PUT). Content data plane: a request to the endpoint host `{name}.azureedge.net` is reverse-proxied to the endpoint's Blob origin (Azurite, path-style) with the origin's `Content-Type` and `Cache-Control` passed through, matching Azure CDN's default origin-honouring behaviour; `GET`/`HEAD` only; delivery-rule TTL overrides are a follow-up. | [cdn_test.go](../internal/arm/cdn_test.go), [cdn_dataplane_test.go](../internal/arm/cdn_dataplane_test.go), [arm_test.go](../test/integration/arm_test.go) | +| CDN (classic) | Full | Full | `azurerm_cdn_profile`, `azurerm_cdn_endpoint` | Full (profile SKU at top level; endpoint `hostName` computed as `{name}.azureedge.net`; cascade delete; parent-existence check on endpoint PUT). Content data plane: a request to the endpoint host `{name}.azureedge.net` is reverse-proxied to the endpoint's Blob origin (Azurite, path-style) with the origin's `Content-Type` and `Cache-Control` passed through, matching Azure CDN's default origin-honouring behaviour; `GET`/`HEAD` only; delivery-rule TTL overrides are a follow-up. Classic CDN was removed from the azurerm provider at v4.35; usable only with `azurerm < 4.35`. | [cdn_test.go](../internal/arm/cdn_test.go), [cdn_dataplane_test.go](../internal/arm/cdn_dataplane_test.go), [arm_test.go](../test/integration/arm_test.go) | +| Front Door (Standard/Premium) | Full | Full | `azurerm_cdn_frontdoor_profile`, `azurerm_cdn_frontdoor_endpoint`, `azurerm_cdn_frontdoor_origin_group`, `azurerm_cdn_frontdoor_origin`, `azurerm_cdn_frontdoor_route` | Full (profile shares the `Microsoft.Cdn/profiles` type with classic CDN, SKU is a no-op; endpoint `hostName` generated as `{name}.azurefd.net`; origin group, origin, and route as child resources with parent-existence checks and cascade delete). Content data plane: a request to `{name}.azurefd.net` walks endpoint to route to origin group to origin and reverse-proxies to the Blob origin (Azurite, path-style) with `Content-Type`/`Cache-Control` passed through; `GET`/`HEAD` only. Custom domains, rule sets, and WAF policies are out of scope (the route uses `link_to_default_domain`). Requires `azurerm >= 4.35`. See design note 5. | [cdn_frontdoor_test.go](../internal/arm/cdn_frontdoor_test.go), [cdn_frontdoor_dataplane_test.go](../internal/arm/cdn_frontdoor_dataplane_test.go) | | User Assigned Identity | Full | N/A | `azurerm_user_assigned_identity` | Full (deterministic `principalId`/`clientId` via SHA-1 UUID for stable plan/apply/refresh; DELETE async 202) | [identity_test.go](../internal/arm/identity_test.go) | | Federated Identity Credential | Full | N/A | `azurerm_federated_identity_credential` | Full (child CRUD under user-assigned identities; issuer/subject/audience rules used by workload identity token exchange) | [federated_identity_credential_test.go](../internal/arm/federated_identity_credential_test.go), [token_test.go](../internal/auth/token_test.go), [workload_identity_test.go](../test/integration/workload_identity_test.go) | | AKS Managed Cluster | Full (stub) | N/A | `azurerm_kubernetes_cluster`, `azurerm_kubernetes_cluster_node_pool` | Full management plane (computed fqdn, default k8s version 1.29.0, SKU + identity at top level, cascade-delete node pools, parent-existence check on pool PUT, listClusterUserCredential / listClusterAdminCredential returning a parseable kubeconfig for the kube_config attribute) | [aks_test.go](../internal/arm/aks_test.go) | diff --git a/website/docs/resources/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md b/website/docs/resources/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md new file mode 100644 index 0000000..db3c831 --- /dev/null +++ b/website/docs/resources/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md @@ -0,0 +1,57 @@ +# Design note 5: Front Door data plane and classic CDN coexistence + +
+Implemented +Date 2026-06-29 +Full text on GitHub → +
+ +
+ +▸ DECISION + +**Add Front Door as a second CDN surface that coexists with classic CDN. Both +control planes and both content data planes run side by side; the host-mux on +the ARM port dispatches by host suffix. Migrate the `static-site` and +`ota-delivery` scenarios to Front Door and lift their `< 4.35` pin; leave +classic CDN in place for users still pinned below 4.35.** + +- The production OTA read path ships Azure Front Door. With classic CDN only, + azemu could not exercise the resource graph under test. +- `azurerm_cdn_frontdoor_profile` is the same ARM type as classic CDN + (`Microsoft.Cdn/profiles`), so the existing profile handler serves it + unchanged. Only the four child types are new: afdEndpoints, originGroups, + origins, routes. SKU (Standard versus Premium) is a no-op. +- An endpoint advertises a deterministic `{name}.azurefd.net` host, so the + control plane and the `*.azurefd.net` data plane agree by construction. The + data plane walks endpoint to route to origin group to origin and proxies to + the Blob origin, the same blob-proxy core the classic `*.azureedge.net` plane + uses. + +
+ +## Consequences + +### Positive + +- `static-site` and `ota-delivery` run at azurerm `>= 4.35` against the real + Front Door resource graph, so the pin is lifted. +- The OTA read path is validated end to end through Front Door, matching + production. +- Two more "Full" entries on the parity matrix: the Front Door child graph and + the `*.azurefd.net` data plane. + +### Trade-offs + +- Two CDN surfaces to maintain, kept small by the shared profile handler and + shared blob-proxy helper. +- Front Door depth is intentionally shallow: no custom domains, rule sets, WAF, + or caching-rule overrides. The route's `link_to_default_domain = true` keeps + the minimal config valid; deeper features are a follow-up. + +### Neutral + +- SKU is stored but inert. A future feature that depends on the tier (private + link, WAF) would change that. +- Origin selection picks the lowest `priority` then highest `weight`. In the + single-origin scenarios this is the only origin. diff --git a/website/mkdocs.yml b/website/mkdocs.yml index cb24f63..52f0119 100644 --- a/website/mkdocs.yml +++ b/website/mkdocs.yml @@ -86,6 +86,7 @@ nav: - resources/design-notes/0002-azemu-plus-kind-for-aks-workload-deployments.md - resources/design-notes/0003-add-azure-cache-for-redis.md - resources/design-notes/0004-azure-devops-pipelines-scope.md + - resources/design-notes/0005-front-door-data-plane-and-classic-cdn-coexistence.md - Community: - Contributing: community/contributing.md - Roadmap: community/roadmap.md From 66a3a35e9cd33e939b4171e39268dc8d460b6f45 Mon Sep 17 00:00:00 2001 From: zerodeth Date: Mon, 29 Jun 2026 20:40:23 +0100 Subject: [PATCH 2/4] fix(cdn): address CodeRabbit review on Front Door PR - CHANGELOG: add missing "that" in the migration description. - Add TestAFDEndpoint_DELETE_CascadesRoutes and TestAFDOriginGroup_DELETE_CascadesOrigins to pin the store's prefix cascade for the Front Door child graph explicitly. The NewRouter-arity and deleteAFDChild-cascade findings were false positives (variadic validators; store.Delete already cascades by prefix) and are answered in the PR threads; the static-site $web-prefix finding is pre-existing classic-CDN read-path behavior, out of scope here. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- internal/arm/cdn_frontdoor_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b38380a..5434293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`azurerm_cdn_profile` / `azurerm_cdn_endpoint`) to Front Door (`azurerm_cdn_frontdoor_*`), lifting their provider pin from `>= 4.0, < 4.35` to `>= 4.35`. Classic CDN was removed at azurerm 4.35; the - scenarios now exercise the Front Door resource graph the production OTA + scenarios now exercise the Front Door resource graph that the production OTA read path ships. ## [v0.3.0] - 2026-06-28 diff --git a/internal/arm/cdn_frontdoor_test.go b/internal/arm/cdn_frontdoor_test.go index 3e5c21a..f694fd2 100644 --- a/internal/arm/cdn_frontdoor_test.go +++ b/internal/arm/cdn_frontdoor_test.go @@ -247,6 +247,30 @@ func TestAFDRoute_PUT_ParentEndpointMissing_Returns404(t *testing.T) { // --- cascade --- +func TestAFDEndpoint_DELETE_CascadesRoutes(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + + // Deleting the endpoint must cascade its routes (store.Delete prefix match). + assertStatus(t, httpDelete(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusNotFound) +} + +func TestAFDOriginGroup_DELETE_CascadesOrigins(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + + // Deleting the origin group must cascade its origins. + assertStatus(t, httpDelete(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusNotFound) +} + func TestAFDProfile_DELETE_CascadesChildren(t *testing.T) { srv := newTestServer(t).URL seedAFDProfile(t, srv, "sub1", "rg1", "fd1") From 392535285b0250a7b90be68d3d48eb685740d17c Mon Sep 17 00:00:00 2001 From: zerodeth Date: Mon, 29 Jun 2026 21:16:30 +0100 Subject: [PATCH 3/4] fix(cdn): address second CodeRabbit reviewer on Front Door PR - CHANGELOG: state the actual pin window >= 4.35, < 4.36 (was >= 4.35). - TODO: narrow the M6 Known Gap to storage scenarios only, since the static-site/ota-delivery Front Door migration lifted theirs. - Strengthen the AFD cascade tests with pre-delete GET assertions so they prove the cascade rather than passing vacuously on a failed setup. Dismissed (replied in threads): static-site www/$web read path is pre-existing example-only behavior (control-plane smoke, no custom domain by design); afdEndpoint name resolution mirrors the classic CDN data plane's documented single-namespace assumption. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- TODO.md | 21 ++++++++++++--------- internal/arm/cdn_frontdoor_test.go | 13 +++++++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5434293..f407b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `static-site` and `ota-delivery` scenarios migrated from classic CDN (`azurerm_cdn_profile` / `azurerm_cdn_endpoint`) to Front Door (`azurerm_cdn_frontdoor_*`), lifting their provider pin from - `>= 4.0, < 4.35` to `>= 4.35`. Classic CDN was removed at azurerm 4.35; the + `>= 4.0, < 4.35` to `>= 4.35, < 4.36`. Classic CDN was removed at azurerm 4.35; the scenarios now exercise the Front Door resource graph that the production OTA read path ships. diff --git a/TODO.md b/TODO.md index dd3f809..f5ad251 100644 --- a/TODO.md +++ b/TODO.md @@ -61,15 +61,18 @@ The bundle file is written with mode 0600 because it contains the private key. to Front Door (`azurerm_cdn_frontdoor_*`), lifting their pin to `>= 4.35`. Classic CDN emulation stays for users still pinned `< 4.35`; classic CDN retires fully 2027-09-30. -- **All scenarios pinned to azurerm `< 4.35`; latest azurerm not yet - supported (M6).** azurerm 4.78+ makes `azurerm_storage_container` parse the - account blob endpoint and require a `core.windows.net` suffix, which - azemu's Azurite path-style endpoints (per design note 1) do not satisfy. To lift - the pin, azemu must return a `*.blob.core.windows.net` blob endpoint that - the provider accepts while still routing container data-plane calls to the - Azurite sidecar (host-based routing, same pattern as the Key Vault - data-plane resolver). Until then, `make tf-test*` runs without `-upgrade` so - the pin holds. +- **Storage scenarios pinned to azurerm `< 4.35`; latest azurerm not yet + supported (M6).** The `storage_account_name` (data-plane) path of + `azurerm_storage_container` parses the account blob endpoint and requires a + `core.windows.net` suffix, which azemu's Azurite path-style endpoints (per + design note 1) do not satisfy. The Front Door scenarios (`static-site`, + `ota-delivery`) lifted to `>= 4.35, < 4.36` (design note 5); the storage + scenarios that exercise this path stay pinned `< 4.35`. To lift them, azemu + must return a `*.blob.core.windows.net` blob endpoint that the provider + accepts while still routing container data-plane calls to the Azurite + sidecar (host-based routing, same pattern as the Key Vault data-plane + resolver). Until then, `make tf-test*` runs without `-upgrade` so the pin + holds. - ~~**Website mirror missing for design notes 2 and 3.**~~ **RESOLVED 2026-05-23.** Both mirrors landed in PR #42 and are registered in `website/mkdocs.yml` nav. Design note 2 status stays `Proposed` until diff --git a/internal/arm/cdn_frontdoor_test.go b/internal/arm/cdn_frontdoor_test.go index f694fd2..60ab2ce 100644 --- a/internal/arm/cdn_frontdoor_test.go +++ b/internal/arm/cdn_frontdoor_test.go @@ -255,6 +255,10 @@ func TestAFDEndpoint_DELETE_CascadesRoutes(t *testing.T) { httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + // Prove the route was actually created, so the post-delete 404 reflects the + // cascade rather than a setup that silently never created it. + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusOK) + // Deleting the endpoint must cascade its routes (store.Delete prefix match). assertStatus(t, httpDelete(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusAccepted) assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusNotFound) @@ -266,6 +270,9 @@ func TestAFDOriginGroup_DELETE_CascadesOrigins(t *testing.T) { httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + // Prove the origin was actually created before asserting the cascade. + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusOK) + // Deleting the origin group must cascade its origins. assertStatus(t, httpDelete(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusAccepted) assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusNotFound) @@ -280,6 +287,12 @@ func TestAFDProfile_DELETE_CascadesChildren(t *testing.T) { httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + // Prove every child exists before the delete, so the post-delete 404s + // reflect the cascade rather than a setup that never created them. + assertStatus(t, httpGet(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1")), http.StatusOK) + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusOK) + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusOK) + assertStatus(t, httpDelete(t, cdnProfileURL(srv, "sub1", "rg1", "fd1")), http.StatusAccepted) // Every child must be gone after the parent profile cascade-deletes. From ee9a2f7dbceb58d806d9f5f910de81b81d02ae17 Mon Sep 17 00:00:00 2001 From: zerodeth Date: Wed, 1 Jul 2026 23:59:42 +0100 Subject: [PATCH 4/4] fix(cdn): address Fable review of Front Door PR Case-sensitive host resolution broke any afdEndpoint with an uppercase name: the generated hostName preserved case while the data-plane host match was case-sensitive too, so the route to the endpoint's own advertised host could never resolve. Lowercase the generated hostName and compare endpoint names case-insensitively (real DNS is case-insensitive). Also: replace the two Msgf (printf-style) log calls with structured fields, add DELETE/HEAD/LIST test coverage for originGroup/origin/route (previously 0%, including the two handlers terraform destroy calls), extend the host-mux test for the *.azurefd.net branch, sort routes by name before picking an origin group so multi-route resolution is deterministic instead of map-iteration-order dependent, always emit tags in the AFD child response (upsertAFDChild never stores a nil Tags map, so the previous nil check was dead code), and sync the ARCHITECTURE.md host-routed-data-planes section and website mirrors for CHANGELOG and architecture docs. Co-Authored-By: Claude Sonnet 5 --- cmd/azemu/serve_test.go | 20 ++- docs/ARCHITECTURE.md | 29 ++-- internal/arm/cdn_frontdoor.go | 16 ++- internal/arm/cdn_frontdoor_dataplane.go | 29 ++-- internal/arm/cdn_frontdoor_dataplane_test.go | 40 ++++++ internal/arm/cdn_frontdoor_test.go | 136 +++++++++++++++++++ website/docs/concepts/architecture.md | 26 ++-- website/docs/reference/changelog.md | 26 ++++ 8 files changed, 282 insertions(+), 40 deletions(-) diff --git a/cmd/azemu/serve_test.go b/cmd/azemu/serve_test.go index 5286b00..47d16c8 100644 --- a/cmd/azemu/serve_test.go +++ b/cmd/azemu/serve_test.go @@ -12,8 +12,10 @@ import ( ) // TestCDNHostMux_routing locks down the real entrypoint: a -// {endpoint}.azureedge.net host must be dispatched to the CDN data plane and -// bypass the ARM router, while every other host falls through to ARM. +// {endpoint}.azureedge.net host must be dispatched to the classic CDN data +// plane, a {endpoint}.azurefd.net host must be dispatched to the Front Door +// data plane, and every other host falls through to ARM. All three bypass +// the ARM router when matched. func TestCDNHostMux_routing(t *testing.T) { ar := arm.NewRouter(store.NewMemoryStore(), "http://azurite:10000", "https://kv", "redis://r:6379") @@ -34,6 +36,20 @@ func TestCDNHostMux_routing(t *testing.T) { t.Fatal("CDN host was routed to the ARM handler instead of ServeCDNContent") } + // Front Door host: handled by ServeAFDContent, so the ARM next handler is + // bypassed. No endpoint is seeded, so it returns 404, but the point is that + // the ARM path was not taken. + nextCalled = false + req = httptest.NewRequest(http.MethodGet, "http://fdedge.azurefd.net/c/blob", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if nextCalled { + t.Fatal("Front Door host was routed to the ARM handler instead of ServeAFDContent") + } + if rec.Result().StatusCode != http.StatusNotFound { + t.Errorf("status = %d, want 404 from the unresolved AFD proxy", rec.Result().StatusCode) + } + // Non-CDN host: falls through to the ARM handler. nextCalled = false req = httptest.NewRequest(http.MethodGet, "http://localhost:4566/subscriptions", nil) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c09fbb8..302bb95 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -92,27 +92,34 @@ This requires: `resourceGroups`; chi routes are lowercase. See `internal/middleware/pathcase.go`. -## Host-routed data planes (Key Vault, CDN) +## Host-routed data planes (Key Vault, CDN, Front Door) -Two Azure data planes are addressed by host rather than by ARM path, so azemu -multiplexes them on the ARM port (`:4566`) behind a host check, the same way -real Azure serves them from distinct hostnames. A wrapper inspects the request -`Host` before the ARM router sees it: +Three Azure data planes are addressed by host rather than by ARM path, so +azemu multiplexes them on the ARM port (`:4566`) behind a host check, the same +way real Azure serves them from distinct hostnames. A wrapper inspects the +request `Host` before the ARM router sees it: - `{vault}.vault.localhost` -> the Key Vault secrets/keys data plane. The `vaultUri` returned by the management plane points here; the handler resolves the vault from the host. See `internal/arm/router.go` (`vaultNameFromHost`). -- `{endpoint}.azureedge.net` -> the CDN content data plane. The handler resolves - the CDN endpoint from the host, finds its Blob origin +- `{endpoint}.azureedge.net` -> the classic CDN content data plane. The handler + resolves the CDN endpoint from the host, finds its Blob origin (`{account}.blob.core.windows.net` -> the storage account), and reverse-proxies the request to Azurite path-style (`{AZEMU_AZURITE_ENDPOINT}/{account}/...`), passing the origin's `Content-Type` and `Cache-Control` through unchanged. That mirrors Azure CDN honouring origin metadata by default. `GET`/`HEAD` only. See `internal/arm/cdn_dataplane.go`. - -Both hosts are covered by wildcard SANs (`*.vault.localhost`, `*.azureedge.net`) -on the self-signed cert, so a client that trusts the azemu cert and resolves the -host to `127.0.0.1` reaches them over TLS on `:4566`. +- `{endpoint}.azurefd.net` -> the Front Door content data plane. The handler + resolves the afdEndpoint from the host, then walks the Front Door resource + graph (afdEndpoint -> route -> originGroup -> origin) to find the Blob + origin, and reverse-proxies the request to Azurite the same way as classic + CDN, reusing the shared blob proxy. `GET`/`HEAD` only. See + `internal/arm/cdn_frontdoor_dataplane.go`. + +All three hosts are covered by wildcard SANs (`*.vault.localhost`, +`*.azureedge.net`, `*.azurefd.net`) on the self-signed cert, so a client that +trusts the azemu cert and resolves the host to `127.0.0.1` reaches them over +TLS on `:4566`. ## Package layout diff --git a/internal/arm/cdn_frontdoor.go b/internal/arm/cdn_frontdoor.go index bd595c8..3ec3ba0 100644 --- a/internal/arm/cdn_frontdoor.go +++ b/internal/arm/cdn_frontdoor.go @@ -69,8 +69,11 @@ func afdRouteID(subID, rgName, profileName, endpointName, routeName string) stri // Real Azure mints "{name}-{hash}.z01.azurefd.net"; azemu uses the stable // "{name}.azurefd.net" so the same value the provider reads from the response // (azurerm_cdn_frontdoor_endpoint.X.host_name) is what the host-mux resolves. +// DNS hostnames are case-insensitive, and the data plane lowercases the +// incoming Host header before matching, so the generated name must already be +// lowercase or an endpoint with an uppercase name would never resolve. func afdGeneratedHostName(endpointName string) string { - return endpointName + afdHostSuffix + return strings.ToLower(endpointName) + afdHostSuffix } // afdChildBody is the PUT payload shared by every AFD child resource. Only the @@ -328,7 +331,7 @@ func (a *Router) upsertAFDChild(w http.ResponseWriter, id, name, typeStr, locati if exists { status = http.StatusOK } - log.Info().Str("resource_id", id).Bool("existed", exists).Msgf("%s upsert", logLabel) + log.Info().Str("resource_id", id).Bool("existed", exists).Str("kind", logLabel).Msg("AFD child upsert") writeJSON(w, status, afdChildResponse(res)) } @@ -356,7 +359,7 @@ func (a *Router) deleteAFDChild(w http.ResponseWriter, r *http.Request, id, subI fmt.Sprintf("The Resource 'Microsoft.Cdn/profiles/.../%s/%s' was not found.", segment, name)) return } - log.Info().Str("resource_id", id).Msgf("AFD %s deleted", segment) + log.Info().Str("resource_id", id).Str("kind", segment).Msg("AFD child deleted") a.acceptAsyncDelete(w, r, subID) } @@ -376,7 +379,8 @@ func (a *Router) writeAFDChildList(w http.ResponseWriter, prefix, typeStr string // the provider's reads of originGroup.id, hostName, linkToDefaultDomain, the // health-probe block, and the rest round-trip exactly as sent. location is // emitted only when set (the afdEndpoint is "global"; the other three child -// types have no location). +// types have no location). tags is always emitted: upsertAFDChild normalises a +// nil Tags map to an empty one, so res.Tags is never nil. func afdChildResponse(res *store.Resource) map[string]interface{} { props := map[string]interface{}{"provisioningState": "Succeeded"} for k, val := range res.Properties { @@ -389,13 +393,11 @@ func afdChildResponse(res *store.Resource) map[string]interface{} { "id": res.ID, "name": res.Name, "type": res.Type, + "tags": res.Tags, "properties": props, } if res.Location != "" { out["location"] = res.Location } - if res.Tags != nil { - out["tags"] = res.Tags - } return out } diff --git a/internal/arm/cdn_frontdoor_dataplane.go b/internal/arm/cdn_frontdoor_dataplane.go index d4a1f8c..efb7e93 100644 --- a/internal/arm/cdn_frontdoor_dataplane.go +++ b/internal/arm/cdn_frontdoor_dataplane.go @@ -4,6 +4,7 @@ import ( "fmt" "net" "net/http" + "sort" "strings" "github.com/zerodeth/azemu/internal/store" @@ -86,12 +87,15 @@ func (a *Router) ServeAFDContent(w http.ResponseWriter, r *http.Request) { } // findAFDEndpoint returns the stored afdEndpoint resource whose name matches, -// scanning the store for the afdEndpoint type. Endpoint names are unique enough -// within a local emulator, the same assumption the classic CDN and Key Vault -// host resolvers make. +// scanning the store for the afdEndpoint type. The comparison is case- +// insensitive because the host name (and so the name this function is called +// with) is always lowercased by afdEndpointNameFromHost, while the stored +// resource name preserves whatever case the provider sent on create. Endpoint +// names are unique enough within a local emulator, the same assumption the +// classic CDN and Key Vault host resolvers make. func (a *Router) findAFDEndpoint(name string) (*store.Resource, bool) { for _, res := range a.store.List("/subscriptions/") { - if res.Type == afdEndpointTypeString && res.Name == name { + if res.Type == afdEndpointTypeString && strings.EqualFold(res.Name, name) { return res, true } } @@ -117,13 +121,18 @@ func (a *Router) resolveAFDOriginAccount(endpoint *store.Resource) (string, bool return blobAccountFromHost(host) } -// routeOriginGroupID returns the originGroup ARM ID referenced by the first -// route under the given afdEndpoint. A minimal Front Door config has a single -// route with link_to_default_domain enabled; if a scenario ever adds multiple -// routes, the first one with an origin-group reference wins (pattern-priority -// selection is out of scope until a scenario needs it). +// routeOriginGroupID returns the originGroup ARM ID referenced by the +// lexicographically first route (by name) under the given afdEndpoint. A +// minimal Front Door config has a single route with link_to_default_domain +// enabled; if a scenario ever adds multiple routes, the name-sorted first one +// with an origin-group reference wins, keeping resolution deterministic +// (store.List backs onto a map, so iteration order alone is not stable +// across requests). Pattern-priority selection is out of scope until a +// scenario needs it. func (a *Router) routeOriginGroupID(endpointID string) (string, bool) { - for _, res := range a.store.List(endpointID + "/") { + routes := a.store.List(endpointID + "/") + sort.Slice(routes, func(i, j int) bool { return routes[i].Name < routes[j].Name }) + for _, res := range routes { if res.Type != afdRouteTypeString { continue } diff --git a/internal/arm/cdn_frontdoor_dataplane_test.go b/internal/arm/cdn_frontdoor_dataplane_test.go index f653fcf..06a02e4 100644 --- a/internal/arm/cdn_frontdoor_dataplane_test.go +++ b/internal/arm/cdn_frontdoor_dataplane_test.go @@ -195,3 +195,43 @@ func TestServeAFDContent_methodNotAllowed(t *testing.T) { t.Errorf("status = %d, want 405", rec.Result().StatusCode) } } + +// TestServeAFDContent_upperCaseEndpointName_resolves guards the case-mismatch +// regression: an endpoint whose name contains uppercase letters (permitted by +// azurerm's naming validation) must still resolve on the data plane, because +// afdGeneratedHostName lowercases the host it advertises and the incoming +// request Host header is also lowercased before endpoint lookup. +func TestServeAFDContent_upperCaseEndpointName_resolves(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer origin.Close() + + a := seedAFDGraph(t, origin.URL, "MyEdge", "otasa") + + req := httptest.NewRequest(http.MethodGet, "http://myedge.azurefd.net/x", nil) + rec := httptest.NewRecorder() + a.ServeAFDContent(rec, req) + + if resp := rec.Result(); resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200 (uppercase endpoint name should still resolve)", resp.StatusCode) + } +} + +func TestIsAFDContentHost(t *testing.T) { + cases := []struct { + host string + want bool + }{ + {"fdedge.azurefd.net", true}, + {"fdedge.azurefd.net:4566", true}, + {"otacdn.azureedge.net", false}, + {"localhost", false}, + } + for _, c := range cases { + if got := IsAFDContentHost(c.host); got != c.want { + t.Errorf("IsAFDContentHost(%q) = %v, want %v", c.host, got, c.want) + } + } +} diff --git a/internal/arm/cdn_frontdoor_test.go b/internal/arm/cdn_frontdoor_test.go index 60ab2ce..8727a1e 100644 --- a/internal/arm/cdn_frontdoor_test.go +++ b/internal/arm/cdn_frontdoor_test.go @@ -110,6 +110,17 @@ func TestAFDEndpoint_PUT_HostNameGenerated(t *testing.T) { } } +func TestAFDEndpoint_PUT_HostNameLowercased(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "MyEdge"), afdEndpointBody) + body := decodeJSON(t, resp) + props := body["properties"].(map[string]interface{}) + if got := props["hostName"]; got != "myedge.azurefd.net" { + t.Errorf("hostName = %v, want myedge.azurefd.net (lowercased so the data plane's case-insensitive host lookup resolves)", got) + } +} + func TestAFDEndpoint_PUT_ParentMissing_Returns404(t *testing.T) { srv := newTestServer(t).URL resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "ghost", "ep1"), afdEndpointBody) @@ -194,6 +205,36 @@ func TestAFDOriginGroup_PUT_ParentMissing_Returns404(t *testing.T) { resp.Body.Close() } +func TestAFDOriginGroup_HEAD_And_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + assertStatus(t, httpHead(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusNoContent) + assertStatus(t, httpHead(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "ghost")), http.StatusNotFound) +} + +func TestAFDOriginGroup_DELETE_Then_GET_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + assertStatus(t, httpGet(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusOK) + assertStatus(t, httpDelete(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1")), http.StatusNotFound) +} + +func TestAFDOriginGroup_LIST_ValueWrapper(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + resp := httpGet(t, fmt.Sprintf( + "%s/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.cdn/profiles/fd1/origingroups", srv)) + body := decodeJSON(t, resp) + items, ok := body["value"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("value = %v, want 1 origin group", body["value"]) + } +} + // --- origin --- func TestAFDOrigin_PUT_Creates_PropsEchoed(t *testing.T) { @@ -216,6 +257,39 @@ func TestAFDOrigin_PUT_ParentGroupMissing_Returns404(t *testing.T) { resp.Body.Close() } +func TestAFDOrigin_HEAD_And_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + assertStatus(t, httpHead(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusNoContent) + assertStatus(t, httpHead(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "ghost")), http.StatusNotFound) +} + +func TestAFDOrigin_DELETE_Then_GET_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusOK) + assertStatus(t, httpDelete(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1")), http.StatusNotFound) +} + +func TestAFDOrigin_LIST_ValueWrapper(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdOriginURL(srv, "sub1", "rg1", "fd1", "og1", "o1"), afdOriginBody).Body.Close() + resp := httpGet(t, fmt.Sprintf( + "%s/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.cdn/profiles/fd1/origingroups/og1/origins", srv)) + body := decodeJSON(t, resp) + items, ok := body["value"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("value = %v, want 1 origin", body["value"]) + } +} + // --- route --- func TestAFDRoute_PUT_Creates_OriginGroupEchoed(t *testing.T) { @@ -245,6 +319,68 @@ func TestAFDRoute_PUT_ParentEndpointMissing_Returns404(t *testing.T) { resp.Body.Close() } +func TestAFDRoute_HEAD_And_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + assertStatus(t, httpHead(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusNoContent) + assertStatus(t, httpHead(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "ghost")), http.StatusNotFound) +} + +func TestAFDRoute_DELETE_Then_GET_404(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusOK) + assertStatus(t, httpDelete(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusAccepted) + assertStatus(t, httpGet(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1")), http.StatusNotFound) +} + +func TestAFDRoute_LIST_ValueWrapper(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), afdEndpointBody).Body.Close() + httpPut(t, afdOriginGroupURL(srv, "sub1", "rg1", "fd1", "og1"), afdOriginGroupBody).Body.Close() + httpPut(t, afdRouteURL(srv, "sub1", "rg1", "fd1", "ep1", "r1"), + afdRouteBody(afdOriginGroupID("sub1", "rg1", "fd1", "og1"))).Body.Close() + resp := httpGet(t, fmt.Sprintf( + "%s/subscriptions/sub1/resourcegroups/rg1/providers/microsoft.cdn/profiles/fd1/afdendpoints/ep1/routes", srv)) + body := decodeJSON(t, resp) + items, ok := body["value"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("value = %v, want 1 route", body["value"]) + } +} + +func TestAFDEndpoint_MissingAPIVersion_Returns400(t *testing.T) { + srv := newTestServer(t) + resp := httpGetRaw(t, afdEndpointURL(srv.URL, "sub1", "rg1", "fd1", "ep1")) + assertStatus(t, resp, http.StatusBadRequest) + body := decodeJSON(t, resp) + errObj := body["error"].(map[string]interface{}) + if errObj["code"] != "MissingApiVersionParameter" { + t.Errorf("code = %v, want MissingApiVersionParameter", errObj["code"]) + } +} + +func TestAFDEndpoint_PUT_InvalidJSON_Returns400(t *testing.T) { + srv := newTestServer(t).URL + seedAFDProfile(t, srv, "sub1", "rg1", "fd1") + resp := httpPut(t, afdEndpointURL(srv, "sub1", "rg1", "fd1", "ep1"), `{not-json`) + assertStatus(t, resp, http.StatusBadRequest) + body := decodeJSON(t, resp) + errObj := body["error"].(map[string]interface{}) + if errObj["code"] != "InvalidRequestContent" { + t.Errorf("code = %v, want InvalidRequestContent", errObj["code"]) + } +} + // --- cascade --- func TestAFDEndpoint_DELETE_CascadesRoutes(t *testing.T) { diff --git a/website/docs/concepts/architecture.md b/website/docs/concepts/architecture.md index baa8943..b055686 100644 --- a/website/docs/concepts/architecture.md +++ b/website/docs/concepts/architecture.md @@ -55,25 +55,31 @@ This requires: - Case-insensitive ARM path normalization: azurerm sends camelCase `resourceGroups`; chi routes use lowercase literals. -## Host-routed data planes (Key Vault, CDN) +## Host-routed data planes (Key Vault, CDN, Front Door) -Two Azure data planes are addressed by host rather than by ARM path, so azemu -multiplexes them on the ARM port (`:4566`) behind a host check, the same way -real Azure serves them from distinct hostnames: +Three Azure data planes are addressed by host rather than by ARM path, so +azemu multiplexes them on the ARM port (`:4566`) behind a host check, the +same way real Azure serves them from distinct hostnames: - `{vault}.vault.localhost` serves the Key Vault secrets/keys data plane. The `vaultUri` returned by the management plane points here, and the handler resolves the vault from the host. -- `{endpoint}.azureedge.net` serves the CDN content data plane. The handler - resolves the CDN endpoint from the host, finds its Blob origin +- `{endpoint}.azureedge.net` serves the classic CDN content data plane. The + handler resolves the CDN endpoint from the host, finds its Blob origin (`{account}.blob.core.windows.net`), and reverse-proxies the request to Azurite path-style, passing the origin's `Content-Type` and `Cache-Control` through unchanged (`GET`/`HEAD` only). This is how Azure CDN honours origin metadata by default. - -Both hosts are covered by wildcard SANs (`*.vault.localhost`, -`*.azureedge.net`) on the self-signed cert, so a client that trusts the azemu -cert and resolves the host to `127.0.0.1` reaches them over TLS on `:4566`. +- `{endpoint}.azurefd.net` serves the Front Door content data plane. The + handler resolves the afdEndpoint from the host, walks the Front Door + resource graph (afdEndpoint -> route -> originGroup -> origin) to find the + Blob origin, and reverse-proxies the request to Azurite the same way as + classic CDN, reusing the same blob proxy (`GET`/`HEAD` only). + +All three hosts are covered by wildcard SANs (`*.vault.localhost`, +`*.azureedge.net`, `*.azurefd.net`) on the self-signed cert, so a client that +trusts the azemu cert and resolves the host to `127.0.0.1` reaches them over +TLS on `:4566`. ## Package layout diff --git a/website/docs/reference/changelog.md b/website/docs/reference/changelog.md index 3f7935a..1bdc86c 100644 --- a/website/docs/reference/changelog.md +++ b/website/docs/reference/changelog.md @@ -21,11 +21,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Azure Front Door (Standard/Premium) support. Four new ARM child types under + the existing `Microsoft.Cdn/profiles` provider: + `azurerm_cdn_frontdoor_endpoint` (afdEndpoints), + `azurerm_cdn_frontdoor_origin_group` (originGroups), + `azurerm_cdn_frontdoor_origin` (origins), and + `azurerm_cdn_frontdoor_route` (routes), each with CRUD, HEAD, LIST, + parent-existence checks, and cascade delete. `azurerm_cdn_frontdoor_profile` + reuses the existing profile handler (the ARM type is shared with classic + CDN; the SKU is a no-op). On create, an endpoint advertises a deterministic + `{name}.azurefd.net` host. A new `*.azurefd.net` content data plane walks + endpoint to route to origin group to origin and reverse-proxies to the Blob + origin (Azurite, path-style), passing the origin's `Content-Type` and + `Cache-Control` through unchanged, mirroring the classic CDN data plane. + Classic CDN (`*.azureedge.net`) is unchanged and coexists. See design note 5. - `redis-cache` scenario: a Redis cache with its connection string stored in Key Vault, the common pattern of provisioning a managed cache and reading its connection details as a secret instead of embedding them. Runs end to end against azemu via `terraform test` and exercises the Redis `listKeys` endpoint and the Key Vault secret data plane. + +### Changed + +- `static-site` and `ota-delivery` scenarios migrated from classic CDN + (`azurerm_cdn_profile` / `azurerm_cdn_endpoint`) to Front Door + (`azurerm_cdn_frontdoor_*`), lifting their provider pin from + `>= 4.0, < 4.35` to `>= 4.35, < 4.36`. Classic CDN was removed at azurerm 4.35; the + scenarios now exercise the Front Door resource graph that the production OTA + read path ships. + +### Added + - CDN content data plane. The CDN was control-plane only: azemu stored the endpoint and computed its `{name}.azureedge.net` host but served nothing through it, so any scenario asserting a CDN read path had to fetch the Blob