Reference-architecture estate deployer

This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at README.md, and llms.txt lists every page.

Deploy-ReferenceArchitecture.ps1 is the single, parameterised, idempotent orchestrator that stands up the active-active, cross-region Orleans.Lattice estate on Azure Container Apps end to end, and the Bicep-native Entra module it drives.

This folder documents and drives the deploy. The Bicep it deploys lives under ../bicep/. The estate-wide validation runbook is in ../README.md.

What it provisions

From one parameter set, across N regions:

  1. Shared registry (../bicep/bootstrap.bicep) - one Azure Container Registry, keyless (no admin user, image pull by managed identity + AcrPull RBAC). Deployed first so the host images can be built before the Container Apps that pull them exist. main.bicep converges onto this same registry.
  2. Host images - built server-side with az acr build straight from ../hosts/{Silo,Mcp,Explorer}/Dockerfile. No image is published to a public registry.
  3. The estate (../bicep/main.bicep) - per-region compute, storage, networking (both options VNet-injected and zone-redundant; the private option adds full-mesh VNet peering), observability, and the global entry point, in the module order main.bicep encodes. The public option provisions a single global Azure Front Door profile; the private option provisions cross-region private DNS instead.
  4. Entra (../bicep/entra/entra.bicep, Microsoft Graph extension) - app registrations, service principals, federated identity credentials, and the app-to-app app-role grants (see below).
  5. Symmetric replication - reciprocal peer enrollment, the estate-wide wire merge mode, and the per-region Key Vault replication-key secret URI, applied to every region.
  6. MCP cross-region targeting - each region's MCP head is given its own region id/cluster id plus a route to every peer region's silo (the same per-region silo FQDNs replication uses), so a single MCP endpoint (on the public option, the Front Door MCP hostname) can serve lattice_list_regions and per-call region-targeted tool calls across the whole estate. Threaded on pass 2 alongside the replication peers.

Two-pass deployment

main.bicep cannot thread two Azure-assigned values in a single pass without forming a Bicep compile cycle, so the script runs them on a second pass:

  • Managed Prometheus query endpoint - activates the silo's KEDA scale rule (see Scaling behaviour) and the MCP cluster-telemetry tools. Each region has its own endpoint. The MCP head queries it with a rotating managed-identity Entra token (the DynamicBearer auth mode shipped by #1286); the region managed identity already holds Monitoring Data Reader on the workspace. When the observability lane is absent the backend is empty and the host leaves the telemetry tool group off.
  • Front Door id (public option) - activates the X-Azure-FDID origin lock on every client-facing head. The private option has no Front Door, so this seam is empty and the lock is not wired.

Because the Prometheus endpoint is per-region and the replication seams are not threaded by main.bicep at all, pass 2 deploys each region's compute.bicep directly. compute.bicep's resource names are pure functions of (baseName, regionCode), so the direct deploy converges onto the exact same Container Apps that pass 1 created - it does not create a second estate. This is the coordinator-sanctioned "script is the orchestrator, main.bicep is the all-at-once convenience template" pattern.

Symmetric replication

  • Each region's replication cluster id is <baseName>-<regionCode>.
  • Its peer endpoint is https://<siloStateApiFqdn> (the silo's external gRPC ingress, which carries state, auth, and replication).
  • For every region the script builds the peer list from every other region, so enrollment is fully reciprocal across all N regions. A region ships only to the peers it lists, so a missing peer leaves that direction without replication; completeness matters.
  • The wire merge mode (-ReplicationTrees, for example orders=LwwRegister,inventory=OrSet) is applied identically estate-wide.
  • The replication key is byte-identical across regions (one Key Vault secret per region, same material). It is a SecureString in and an @secure() Bicep parameter out - never written to disk, never logged, never an output.

MCP cross-region targeting

The MCP head fronts its co-located silo by default, but on the public option one global Front Door sits in front of every region, so the script also wires each head to reach its peers directly (it wires the same peers on the private option):

  • Each head advertises its own region (Mcp:RegionId = the region code, Mcp:ClusterId = <baseName>-<regionCode>), surfaced by lattice_list_regions.
  • The peer set is exactly the replication peer set: every other region, dialed at its DIRECT region-pinned silo gRPC FQDN (https://<siloStateApiFqdn>, never the anycast Front Door hostname). That single endpoint serves every facade group, so a caller can pass an optional region on any tool call to pin it to a region.
  • Mcp:VerifyRegionIdentity is enabled whenever peers exist: before routing to a peer the head probes its state facade and compares the reported cluster id to the advertised one, rejecting fail-closed a region whose endpoint does not actually reach the expected cluster. The peers use direct FQDNs, so the assertion passes.
  • On the public option the head stamps the shared X-Azure-FDID origin-lock header on every cross-region call, exactly as it does for its co-located silo, so the peer silo accepts it. The private option has no Front Door id, so the head stamps no header and the peer silos carry no lock.

Threaded on pass 2 (the peer FQDNs are only known after pass 1), reusing the same perRegion[].siloStateApiFqdn values the replication peer list is built from.

Entra design (federated identity, no secrets)

entra.bicep creates three app registrations - silo facade, MCP endpoint, and Explorer - each with a service principal. Instead of client secrets it authors federated identity credentials: one per region managed identity, on the silo, MCP, and Explorer apps. A workload therefore obtains an app token from its own managed identity with no secret to store, rotate, or leak.

  • The silo facade app declares an application app role, Lattice.Access, granted to the MCP service principal - the app-to-app (client-credentials) authorization edge. The silo app also declares a delegated user_impersonation scope; the Explorer console signs operators in (OpenID Connect) and calls the facade on-behalf-of them, so it is granted that delegated scope (admin-consented declaratively via an oauth2PermissionGrant) rather than the app role. Least privilege either way: a single purpose-named grant per caller.
  • The silo app declares the Microsoft Graph GroupMember.Read.All application permission its optional group resolver needs, and entra.bicep grants tenant admin consent for it declaratively - an appRoleAssignedTo from the silo service principal to the Microsoft Graph service principal's app role, which is exactly what az ad app permission admin-consent creates. There is therefore no imperative consent step. The grant is idempotent, and the deploying identity must hold a privileged directory role (for example Privileged Role Administrator, or the AppRoleAssignment.ReadWrite.All + Application.ReadWrite.All application permissions) for it to succeed.

No passwordCredentials are authored and nothing secret is emitted as an output. The app (client) ids the module outputs are public identifiers.

Secret-less Microsoft Graph (managed identity)

The federated credentials provisioned above are consumed directly by the silo host. When Entra is enabled the silo authenticates its app-only Microsoft Graph group resolver with the region's user-assigned managed identity (via DefaultAzureCredential, resolved through AZURE_CLIENT_ID) against the federated credential on the app registration - no client secret is stored, injected, or rotated. Compute sets Entra__Graph__UseManagedIdentity=true on the silo whenever Entra is on. The secret-less TokenCredential path in the core Orleans.Lattice.Membership.Entra.Graph package (8.0.1) landed via #1291. A Entra:Graph:ClientSecret is still accepted as a dev / back-compat override and takes precedence when supplied.

Initial access: the single security administrator

When Entra is enabled the estate is deny-by-default: no caller can read or write until a subject is authorized. The deployer seeds exactly one root-of-trust administrator - the -SecurityAdmin (an Entra object id or UPN / email resolved to its object id), defaulting to the currently signed-in deploying user. That object id is threaded to every region's silo as Auth:BootstrapAdministrators, matched on the Entra oid claim. Only that administrator can reach the estate after the first deploy; they then grant further operators access at runtime through the Explorer Access area, which is itself administrator-gated (every membership / policy write requires an Admin verdict on the authorization tree).

Running it

Invoke it with a full parameter set (recommended). Splat the parameters and read the two SecureStrings so nothing secret is echoed:

$key = Read-Host -AsSecureString 'Replication key'
$gpw = Read-Host -AsSecureString 'Grafana admin password'

./Deploy-ReferenceArchitecture.ps1 `
    -SubscriptionId <sub-guid> `
    -ResourceGroup rg-lattice `
    -Location uksouth `
    -BaseName lattice `
    -Regions @(@{ regionCode = 'uks'; location = 'uksouth' }, @{ regionCode = 'wus'; location = 'westus3' }) `
    -ImageTag 2025.07.29 `
    -ReplicationKey $key `
    -GrafanaAdminPassword $gpw `
    -EntraEnabled -EntraTenantId <tenant-guid>

Running it with no arguments drops into PowerShell's per-parameter prompt for the mandatory parameters (-SubscriptionId, -ResourceGroup, -Location, -BaseName, -Regions, and the -GrafanaAdminPassword SecureString). The prompt cannot build the -Regions hashtables, so give each region in the compact regionCode=location form, one per line, and a blank line to finish:

Regions[0]: uks=uksouth
Regions[1]: wus=westus3
Regions[2]:

-ImageTag and -ReplicationKey are not prompted for: pass them on the command line (for example -ImageTag 2025.07.29 -ReplicationKey $key), or the run stops with an actionable error before it touches Azure.

Add -WhatIf to preview without mutating Azure: it prints each az command up to and including the pass-1 deployment, then stops, because the Entra deployment and pass 2 need pass 1's outputs.

Quick start: the three-region sample

For a zero-decision evaluation estate, deployment-sample.ps1 wraps this deployer and needs only a deployment name. It fixes the three regions to East US 2, West US 3, and West Europe, derives the base name (the name itself) and the resource group (rg-<name>) from the name, and generates the replication key and Grafana admin password for you (the password is printed once at the end, to use as the admin user at the per-region Grafana URLs the deployer lists under its estate endpoints). It deploys the public network option with Entra sign-in on.

It first requires an authenticated Azure CLI session (it errors out asking you to run az login if none is present), then resolves the target subscription (the current az context, or -SubscriptionId) and its tenant, prints them with the signed-in user, and asks you to confirm before creating anything:

# Deploy into the current 'az' subscription (you are shown it and asked to confirm).
./deployment-sample.ps1 -DeploymentName demo

The deployment name must be 3 to 16 lowercase letters or digits. Pass -Force to skip the confirmation prompt, or -WhatIf to preview. Its only other parameters are -ImageTag (default sample) and -EntraTenantId (default: the target subscription's tenant). For any other topology (private networking, a different region set, a pre-existing Entra app), drive Deploy-ReferenceArchitecture.ps1 directly as above.

Parameters

Parameter Required Notes
-SubscriptionId yes Target subscription.
-ResourceGroup yes Created if absent (idempotent).
-Location yes Resource-group location.
-BaseName yes 3-16 lowercase alphanumerics, shared estate-wide.
-Regions yes One or more regions. Each entry is a hashtable @{ regionCode = '<2-8 chars>'; location = '<azure region>' } or the compact string 'regionCode=location' (for example 'use=eastus'); the string form is what the interactive prompt accepts.
-ImageTag yes Non-empty tag applied to all three built images (silo / MCP / Explorer).
-SiloImageRepository / -McpImageRepository / -ExplorerImageRepository no Registry repository names for the three built images (defaults lattice-silo / lattice-mcp / lattice-explorer).
-DeploymentOption no public (default, external ingress + replication key over public ingress) or private (internal ingress + VNet peering, replication key layered on as defense in depth). Both are VNet-injected + zone-redundant, and both provision the per-region replication Key Vault.
-ZoneRedundant no $true (default) or $false. Zone-redundant compute for both options.
-ReplicationTrees no Estate-wide treeName=MergeMode,... map.
-BackupPrimaryRegionCode no Defaults to the first region.
-IngressAllowedCidrs no Ingress allow-list seam (public option). Currently only echoed as a networking module output; no ingress applies it yet.
-SiloMinReplicas / -SiloMaxReplicas no Silo scale floor (default 1) and ceiling (default 3), each 1-100.
-AuthDefaultEffect no Deny (default) or Allow (throwaway dev only).
-RequireApiAuthorization no Default $true: the silo facades and the MCP endpoint require authorization.
-ReplicationKey yes SecureString, stable across runs. Required by both options.
-GrafanaAdminPassword yes SecureString.
-EntraEnabled / -EntraTenantId Entra Enable and target tenant.
-EntraClientId no Use a pre-existing audience app instead of deploying entra.bicep.
-ExplorerWebClientId no Explorer console web-app (client) id, used only with -EntraClientId; otherwise read from the entra.bicep explorerClientId output.
-EntraAudiences no Extra accepted token audiences.
-SecurityAdmin no The single Entra security administrator seeded as the sole initial-access principal (root of trust). Object id (GUID) or UPN / email (resolved to an object id). Defaults to the deploying user when Entra is enabled; add further administrators at runtime via the Explorer Access area.
-EnableDataApi no $true (default) exposes the read-write Data API; -EnableDataApi:$false withholds the write surface.
-EnableReplicationControl no $true (default) co-hosts the runtime per-tree replication control plane (silo control binding plus the MCP lattice_replication_* tools), fail-closed behind an authored Replication grant; -EnableReplicationControl:$false withholds it.
-EnableBackupControl no $true (default) makes the MCP head advertise the backup tool group, fail-closed behind an authored Backup grant; -EnableBackupControl:$false withholds it.
-EnableDigestAntiEntropy / -DigestProbeIntervalSeconds no $false / 0 (defaults). Cross-cluster anti-entropy applied to every region; the interval optionally shortens the probe cadence.
-ExplorerRedirectUris no Defaults derived from the deployed FQDNs.
-SkipImageBuild no Reuse images already present at -ImageTag.
-WhatIf no Preview without mutating Azure. Prints each az command through the pass-1 deployment, then stops (the Entra deployment and pass 2 need pass 1's outputs).

Idempotency and re-runs

Every step converges:

  • az group create and the ARM deployments are declarative and idempotent.
  • az acr build overwrites the same tag.
  • RBAC is declarative (Bicep modules assign managed-identity data-plane RBAC; entra.bicep assigns the app-to-app app role), so re-runs never duplicate role assignments.
  • Federated identity credentials and app registrations are keyed by stable names, so a re-run updates in place.

Supply the same -ReplicationKey on every run; rotating it stalls cross-region replication - a receiver refuses the mismatched key, and the shipper holds its place and retries - until every region runs with the new key.

Static validation (no Azure required)

This deployer is validated without touching Azure:

# Bicep compiles clean (zero warnings). Delete generated JSON after.
az bicep build --file ../bicep/bootstrap.bicep
az bicep build --file ../bicep/entra/entra.bicep

# PowerShell parses with zero errors.
$e = $null
[System.Management.Automation.Language.Parser]::ParseFile(
    "$PWD/Deploy-ReferenceArchitecture.ps1", [ref]$null, [ref]$e); $e.Count

# Lint clean.
Invoke-ScriptAnalyzer -Path ./Deploy-ReferenceArchitecture.ps1

entra.bicep uses the Microsoft Graph Bicep extension (GA), enabled by the bicepconfig.json colocated in ../bicep/entra/. It is kept in its own folder so the extension/experimental config does not apply to main.bicep or the ARM modules, which build with stock Bicep defaults.