Onboarding a New Environment

A "new environment" is an additional Memex portal β€” its own domain, database, and sign-in β€” running on the shared AKS cluster (<aks-cluster> / <aks-resource-group>, swedencentral).

🚨 This page is the MECHANISM, not the runbook. Per-environment folders moved OUT of this repository on 2026-08-08/09 (commit a69959165) β€” their directory names are tenant identities. The maintained, ordered operator procedure lives in the private Systemorph/Memex repository (docs/new-deployment.md, with the inventory in deployments/aks/envs.json), and that copy is authoritative wherever this page and it disagree. What remains here is the part that is genuinely public: what is shared vs separate, the chart's config pass-through rule, the self-update model, and the gotchas. The only env folder still in this repository is the reference template deploy/aks/envs/example/, kept as a shape to copy β€” not as the place a real environment lives.

The shared platform (cluster, ingress, Postgres server, Key Vault, ACR) is brought up once β€” see the AKS deployment sample (deploy/aks/README.md in the repository); this guide adds an environment on top of it.

Shared vs. separate

Resource Shared across envs Separate per env
AKS cluster + node pools βœ… <aks-cluster>
Ingress controller (app-routing nginx, one public IP) βœ…
Postgres server βœ… <pg-server> database (<database>, …)
Key Vault βœ… Systemorph secret names (<env>-*) + master key
ACR + portal image βœ… meshweaver.azurecr.io/memex-portal-ai image tag (a commit sha)
Kubernetes namespace βœ… <env>
Public host + TLS cert βœ… <host> + <env>-tls
Entra app (sign-in) βœ… its own app registration

1. Scaffold the env folder

Copy the reference template deploy/aks/envs/example/ β€” the real env folder is created in the private ops repository, not here (see the note above). These are the files it contains and what each one carries:

File What to change
values.<env>.yaml host, MEMEX_DATABASENAME, TLS secretName, AI + auth config, resources, selfUpdate.azureClientId (the shared portalIdentityClientId β€” same value for every env)
portal-pvcs.yaml namespace: <env> on every PVC
portal-ingress.yaml namespace, host, TLS secret, affinity cookie name
secretproviderclass.yaml legacy β€” declare the secrets under keyVaultSecrets: in values.<env>.yaml instead (the chart renders the SecretProviderClass, the CSI volume, its mount and the envFrom; see DeploymentAKS β†’ "Key Vault secrets are DECLARED in values")
portal-patch.json (usually unchanged β€” binds PVCs; the CSI secret mount + envFrom now render from keyVaultSecrets)
deploy.sh / tls.sh NS, RELEASE, host

values.<env>.yaml and secretproviderclass.yaml are git-ignored (see deploy/aks/envs/.gitignore) β€” they carry deployment-specific ids/sender/KV refs and are managed out-of-band. The scripts read them from disk.

2. Provision Azure (control-plane; no cluster access needed)

RG=<aks-resource-group>; PG=<pg-server>; KV=Systemorph; ZONE=meshweaver.cloud
INGRESS_IP=$(az aks command invoke -g $RG -n <aks-cluster> \
  --command "kubectl get svc -n app-routing-system nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}'" --query logs -o tsv | tr -d '\r\n ')
# 1. Database on the shared server
az postgres flexible-server db create -g $RG -s $PG -d <env>
# 2. DNS A-record -> the SHARED ingress IP
az network dns record-set a add-record -g dns -z $ZONE -n <sub> --ipv4-address "$INGRESS_IP" --ttl 300
# 3. Sign-in app (MULTI-TENANT so any org can sign in; invitation-only gates access)
az ad app create --display-name "<Env> Portal (<host>)" \
  --sign-in-audience AzureADMultipleOrgs \
  --web-redirect-uris "https://<host>/signin-microsoft"
az ad app credential reset --id <appId> --display-name <env> --years 1   # -> client secret
# 4. KV secrets. FRESH master key only for an EMPTY db; for a MIGRATED db REUSE the
#    source's master key (else stored enc: provider keys become undecryptable).
az keyvault secret set --vault-name $KV --name <env>-Ai-KeyProtection-MasterKey --value "$(openssl rand -base64 32)"
az keyvault secret set --vault-name $KV --name <env>-Authentication-Microsoft-ClientSecret --value "<entra-secret>"
# 5. Self-update (ACR polling): federate the SHARED portal UAMI to THIS namespace's memex-portal-sa
#    so the in-pod self-updater can list ACR tags. Preferred: add the namespace to `portalNamespaces`
#    in infra/main.bicep and re-run the (idempotent) infra deploy. Quick out-of-band equivalent:
ISSUER=$(az aks show -g $RG -n <aks-cluster> --query oidcIssuerProfile.issuerURL -o tsv)
az identity federated-credential create -g $RG --identity-name <portal-identity> \
  --name "memex-portal-<env>" --issuer "$ISSUER" \
  --subject "system:serviceaccount:<env>:memex-portal-sa" --audience "api://AzureADTokenExchange"
# The shared UAMI already has AcrPull on meshweaver.azurecr.io β€” set its clientId as
# selfUpdate.azureClientId in values.<env>.yaml (same value as every other env):
PORTAL_MI_CLIENT_ID=$(az identity show -g $RG -n <portal-identity> --query clientId -o tsv); echo "$PORTAL_MI_CLIENT_ID"

<env> is the Kubernetes namespace. The federated-credential subject must be EXACTLY system:serviceaccount:<env>:memex-portal-sa β€” a mismatch silently fails the ACR token exchange (the in-pod deployment PATCH still works; only tag discovery is blocked). See DeploymentAKS β†’ Portal self-update.

3. Deploy + issue TLS

🚨 The connection host is the server FQDN, not a private IP. The servers moved on 2026-08-24, and a stale private-IP address here would revert that migration. The FQDN is safe with a password: AzurePostgres.UsesManagedIdentityAuth (MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/AzurePostgres.cs) takes the Entra-token path only when the host carries the Azure suffix AND no password is present, so FQDN + password deliberately takes plain Npgsql with SslMode=Require β€” pinned by AzurePostgresAuthSelectionTests.

STAGE=$(mktemp -d); cp <env-folder-from-the-private-ops-repo>/* "$STAGE"/; cp -r deploy/helm "$STAGE"/helm
export MEMEX_PG_CONN='Host=<pg-server>.postgres.database.azure.com;Port=5432;Username=memexadmin;Password=<PW>;Database=<env>;SslMode=Require;Trust Server Certificate=true'
export IMAGE_TAG=<sha>
( cd "$STAGE" && az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
    --command "MEMEX_PG_CONN='$MEMEX_PG_CONN' IMAGE_TAG='$IMAGE_TAG' bash deploy.sh" --file . )
# Verify BEFORE DNS/TLS (host still unresolved or pointing elsewhere):
curl -sS -k -o /dev/null -w "%{http_code}\n" --resolve <host>:443:$INGRESS_IP https://<host>/
# Then issue the cert (needs the A-record to resolve publicly):
( cd <env-folder-from-the-private-ops-repo> && az aks command invoke -g <aks-resource-group> -n <aks-cluster> --command "bash tls.sh" --file tls.sh )

Self-update: first-install checklist

A new environment should run on self-update from day one β€” that is the steady state. A new instance is a Deployments/<name> record provisioned by a Provision Hosting/InstanceAction on the control instance (see /new-deployment and OperatingFromThePortal); the az/kubectl steps on this page are the pre-record procedure, kept as the record of how the existing environments were built and as break-glass. The manual AKS runbook (kubectl set image + rollout) is what a Roll action runs, and the break-glass path only. Once per environment, in this order:

  1. Deploy the portal-identity bicep. It provisions the shared portal UAMI (<portal-identity>) + one federated credential per portal namespace (deployPortalIdentity: true, default). For a brand-new namespace, add it to portalNamespaces and re-run the (idempotent) infra deploy, or create the federated credential out-of-band (Β§2 step 5). The subject must be exactly system:serviceaccount:<env>:memex-portal-sa.
  2. Grant the portal UAMI AcrPull on the shared ACR. meshweaver.azurecr.io lives in meshweaver-shared β€” cross-RG from <aks-resource-group> β€” so grant it out-of-band exactly like the kubelet's grant (or set grantSharedAcrPull=true for pure-IaC). One grant covers every namespace (one shared UAMI). Without it the in-pod Deployment PATCH still works; only ACR tag discovery is blocked.
  3. Set selfUpdate.azureClientId in values.<env>.yaml to the shared portalIdentityClientId (the same value for every env). This authenticates the tag-list call; the chart wires the workload-identity annotation/label + AZURE_CLIENT_ID from it.
  4. Set Admin/UpdatePolicy for the env. Settings β†’ Updates (platform admin) writes the Admin/UpdatePolicy node. The seeded default is Stable (rolls only to the newest clean release). For dev/test set Continuous with a pattern β€” 3.0.0-ci* today β€” so the install follows that line's build-numbered images; Continuous without a pattern is Stable. A new install can seed that from the chart: SelfUpdate__DefaultPolicy=Continuous + SelfUpdate__DefaultPattern=3.0.0-ci*. See Release & Self-Update Strategy.
  5. Add the env's Azure Files share to the CI bake targets β€” otherwise no published bundle ever reaches the new portal and its pods Roslyn-compile every shipped NodeType at boot. Append its <account>/<share>[/<base-path>] (the account/share behind the namespace's memex-data PVC) to the BAKE_PUBLISH_TARGETS repo variable on the platform repo and on each satellite content repo, and make sure the github-actions-bake identity holds Storage File Data Privileged Contributor on that storage account. The publishing jobs preflight this red, never skipped β€” see The Continuous Delivery Contract, which also carries the GitHub OIDC subject-format rule (register BOTH the classic and the immutable subject per repo). Note that identity's federated subjects come from GitHub's issuer and are unrelated to the cluster-issuer system:serviceaccount: subjects in steps 1–3.

Plugins β€” wire the environment to a registry

A new environment starts with no plugins, and its Settings β–Έ Administration β–Έ Plugin Catalog tab reads "not configured" until you point it at a registry. Plugins live in (usually private) git repos; one installation is the registry β€” it alone holds the git credential and re-serves the catalog over HTTP β€” and every other installation is a credential-free consumer.

Consumer (the normal case) β€” in values.<env>.yaml:

pluginCatalog:
  registryUrl: "https://<registry-host>"      # or `registries: [{name, url, ref}]` for several
config:
  memex_portal:
    PluginCatalog__AutoUpdateByDefault: "true"   # installs track their repo; install-time seed only
secrets:
  memex_portal:
    PluginCatalog__RegistryToken: "<token issued to this installation>"

The consumer's token is the mwi_ instance key issued when the new installation is registered on the registry portal (Settings β–Έ Instances β€” self-service, shown once). Registration grants nothing by itself; what the instance may pull is decided per (source, package) by a platform admin on the registry β€” except sources the registry opted into PluginCatalog:DefaultGrants (typically the platform Plugins/* repo), which every new registration is granted automatically. So with defaults configured, a fresh environment needs no admin grant step to see the platform plugins β€” install them from the Plugin Catalog tab once the consumer wiring lands.

Zero-touch alternative β€” auto-registration on first boot. Skip the register-and-copy step entirely: set pluginCatalog.instanceId in the values file and the PluginCatalog__BootstrapKey secret (an admin-minted mwr_ registration key from the registry's Instance grants β–Έ Registration keys section β€” one key serves the whole scaffold and is revocable). On first startup the portal registers itself at the registry, receives its mwi_ key and stores it (Admin/PluginRegistryCredential/…, encrypted with the master key); DefaultGrants then fill the catalog with no admin involved. An explicitly configured PluginCatalog__RegistryToken always wins over the stored credential.

Registry (only when this environment is the registry):

pluginCatalog:
  sources:
    - {name: Plugins, repoPath: "https://github.com/<org>/<plugins-repo>", ref: main}
  defaultGrants: ["Plugins/*"]   # granted to every NEW registration; never private/paid sources
secrets:
  memex_portal:
    PluginCatalog__RegistryTokens: ["<token-per-registered-installation>"]

🚨 A registry with an empty RegistryTokens list answers ANY anonymous caller with the full catalog and every package's file content β€” that is the local-dev / e2e stub mode. Always configure tokens on a production registry, and verify with an unauthenticated curl https://<registry-host>/api/plugins (want 401). Prefer sourcing the token from Key Vault via the SecretProviderClass over putting it in the values file.

Sign-in, invitations, email

Migrating an existing portal (data move)

For a portal moving off another platform (e.g. ACA β†’ AKS), in addition to the above:

  1. Reuse the source master key in the env's KV (decrypts stored enc: provider keys).
  2. DB: pg_dump --no-owner --no-acl the source β†’ restore into the env's database. The source may be Entra-auth only β€” dump from an in-cluster pod with an AAD token (an Entra admin on the source server) and a temporary firewall rule for the AKS egress IP.
  3. Content: copy the blob content collection β†’ the /mnt/content Azure Files share.
  4. Verify on the ingress IP (--resolve), then cut DNS over, keeping the old platform as rollback.

See Memex Cloud Deployment for the prod-grade specifics.

🚨 Gotchas (learned the hard way)

Verifying a rollout (and what "normal turbulence" looks like)

A new image means fresh pods, and every dynamic NodeType's cached assembly is ABI-stale against the new framework build β€” so they all recompile. Expect a window where pages and /api/content/… return errors like "No response received … for request GetDataRequest/SubscribeRequest β†’ target X". That is cold-compile, not a bad image.

# 1. BEFORE rolling: the manifest must have every arch leg. A partial manifest list is an
#    ImagePullBackOff on the missing arch, which reads as "the deploy hung".
az acr manifest show -r <registry> -n memex-portal-ai:<tag> \
  | jq -r '.manifests[]?.platform | "\(.os)/\(.architecture)"'

# 2. Roll and wait. The cluster is PRIVATE β€” these kubectl lines only work from inside it, so in
#    practice wrap them: az aks command invoke -g <rg> -n <cluster> --command "…"
kubectl set image deploy/memex-portal-deployment memex-portal=<registry>/memex-portal-ai:<tag> -n <env>
kubectl rollout status deploy/memex-portal-deployment -n <env> --timeout=600s

# 3. Verify with real signals, in a LOOP (one probe can hit a warm pod and lie).
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " https://<host>/api/content/<space>/content/<file>; done
Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.