Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, < 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.

Comment thread
coderabbiteu[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
## [v0.3.0] - 2026-06-28

### Added
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
10 changes: 6 additions & 4 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 20 additions & 17 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,26 @@ 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.
- **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.
- ~~**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.
Comment thread
coderabbiteu[bot] marked this conversation as resolved.
- **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
Expand Down
36 changes: 21 additions & 15 deletions cmd/azemu/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
})
}

Expand Down
20 changes: 18 additions & 2 deletions cmd/azemu/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
Expand Down
29 changes: 18 additions & 11 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
Loading
Loading