Deployment Options (AKS)

How to change what runs at portal.example.com, from "instant, no redeploy" to "full image release". Each option below is independent — pick the lightest one that does the job. This page exists because several of the options have non-obvious constraints (a private API server, CI-only image builds, and a master key that must not be overwritten).

The environment, in one table

Fact Value Consequence
Cluster <aks-cluster> / rg <aks-resource-group> (Sweden Central)
API server private (…privatelink…azmk8s.io) kubectl/helm from outside the VNet cannot reach it — use az aks command invoke or an in-VNet runner/VPN
Key Vault CSI azureKeyvaultSecretsProvider add-on enabled — its identity clientId comes from az aks show … --query addonProfiles.azureKeyvaultSecretsProvider.identity.clientId secrets can come from Key Vault, keyless
Key Vault <key-vault> holds AzureFoundry-ApiKey
Images Live pods pull from ACR meshweaver.azurecr.io. The chart's default is ghcr.io/systemorph/memex-portal-ai:latest, which the AKS deploy script repoints to ACR Two CI channels build them, plus a manual path — see below
portal-ai base bakes @anthropic-ai/claude-code + @github/copilot CLIs the claude CLI is present in the running pod
AI model picker fed by ModelProvider / LanguageModel mesh nodes see Setting Up Model Providers

Option A — Live config via mesh nodes (no redeploy) ✅ used now

The model picker reads ModelProvider + LanguageModel mesh nodes. Create/patch them through the portal (MCP / Settings) and they take effect immediately — the data lives in Postgres, the running pod serves it, no deploy.

Best for: adding/curating models, fixing an empty picker, per-user keys. Limitation: these are instance nodes in a space/user partition — not the default catalog served to every partition (that's Option B). The encrypted key depends on the master key staying constant (see the caveat).

Option B — Default static catalog via Helm config (needs redeploy)

BuiltInLanguageModelProvider materialises a default catalog at Provider/{provider} + nested model children from config ({Section}:Models / :Endpoint) — imported into the top-level Provider partition on boot and served from the DB. The tier→model map now lives on the model NODES ("tier": "coding" — see Model Tiers); the ModelTier:Heavy/Standard/Light/Utility keys below are the deprecated shim, still read so an existing deployment keeps its mapping. The AKS overlay (deploy/aks/values.aks.yaml) sets:

AzureFoundry__Endpoint = https://<foundry-account>.services.ai.azure.com/models
AzureFoundry__Models__0/1/2 = DeepSeek-V4-Pro, DeepSeek-V3-0324, DeepSeek-V4-Flash
ModelTier__Heavy/Standard/Light/Utility = V4-Pro / V3-0324 / V4-Flash / V4-Flash
ClaudeCode__ConfigDirRoot = /mnt/users

The chart templates these in deploy/helm/templates/memex-portal/config.yaml (+ secrets.yaml); base values.yaml defaults them empty (neutral chart). Takes effect on the next helm upgrade (Option E), not before.

Best for: the org-wide default catalog + agent tier mapping. Anthropic/Claude is intentionally not wired here — Claude is per-user Claude Code (Option D).

Option C — Secrets via Key Vault + CSI (keyless, no committed keys)

The real AzureFoundry key lives in Key Vault, mounted by the CSI add-on and synced into a K8s Secret the portal reads via envFrom.

  1. Store the secret: az keyvault secret set --vault-name <key-vault> --name AzureFoundry-ApiKey --value <key>.
  2. Grant the add-on identity read access (once): az role assignment create --assignee <csi-addon-identity-clientId> --role "Key Vault Secrets User" --scope <key-vault resourceId> (or an access policy with secret get/list).
  3. Declare it under keyVaultSecrets: in the environment's values (vaultName, tenantId, identityClientId, and secrets: [{vaultSecret: AzureFoundry-ApiKey, key: AzureFoundry__ApiKey}]) — the chart renders the SecretProviderClass, the CSI volume, its mount and the envFrom, so steps 3–4 of the legacy path below are no longer hand-applied. See DeploymentAKS → "Key Vault secrets are DECLARED in values". (Legacy, hand-made shape — template at deploy/aks/envs/example/secretproviderclass.yaml; the real one is per-environment and git-ignored.)
  4. (Legacy only) Patch the portal Deployment to mount it + read the synced secret:
# strategic-merge patch on the portal container/pod
spec:
  template:
    spec:
      containers:
        - name: memex-portal
          envFrom:
            - secretRef: { name: memex-portal-ai-secrets }   # synced by the SPC
          volumeMounts:
            - name: kv-ai-secrets
              mountPath: /mnt/secrets-store
              readOnly: true
      volumes:
        - name: kv-ai-secrets
          csi:
            driver: secrets-store.csi.k8s.io
            readOnly: true
            volumeAttributes:
              secretProviderClass: memex-portal-ai-secrets

Best for: keeping the key out of git/values. Pairs with Option B (B says which models; C supplies the key).

Option D — Claude Code (per-user, Claude on your own subscription)

Claude is not a shared org key. Each user connects the co-hosted Claude Code CLI under their own account in Settings → Models → Connect: the portal runs claude setup-token under {ClaudeCode:ConfigDirRoot}/{userId}/.claude, captures the token, and stores an encrypted {user}/_Memex/ClaudeCode provider that injects Claude into that user's picker.

Option E — Code changes → new image → deploy

Anything in .cs (e.g. the Claude Code PTY fix, the static-catalog behaviour) only ships in a new image:

  1. Build + push. Three paths, and the normal one is the first:
    • Continuous (default): merge to mainmain-cd.yml builds and pushes 3.0.0-ci.<n> to ACR (meshweaver.azurecr.io). This is what the portals self-update onto. It also runs on an hourly reconcile schedule and workflow_dispatch.
    • Official release: push an annotated v*.*.* tag on a promoted, sealed commit → release.yml retags that set with the clean version in ACR and mirrors it to GHCR. Nothing is rebuilt (Release Process).
    • Manual (break-glass): dotnet publish -t:PublishContainer straight to ACR — see DeploymentAKS.md §1. 🚨 Only usable with the self-updater paused, because a non-SemVer tag is not a self-update candidate and the poller will roll the Deployment back off it.
  2. Roll the cluster to it — as a Roll (image) or HelmRelease deploy / Reconcile (config) Hosting/InstanceAction on the control instance (OperatingFromThePortal). The commands below are what the operator runs; typed by hand they are break-glass, server-side via az aks command invoke because the API server is private:
# config/manifest changes (works against the CURRENT image). The SecretProviderClass is
# per-environment and NOT in this repo — take it from the environment's own folder (template:
# deploy/aks/envs/example/secretproviderclass.yaml):
az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
  --command "kubectl apply -f secretproviderclass.yaml && kubectl rollout restart deploy/memex-portal-deployment -n <ns>" \
  --file <env-dir>/secretproviderclass.yaml

# helm upgrade (uploads the chart + values to the in-cluster run pod, which has helm):
az aks command invoke -g <aks-resource-group> -n <aks-cluster> \
  --command "helm upgrade <release> ./helm -f ./helm/values.yaml -f values.aks.yaml -n <ns>" \
  --file deploy/helm --file deploy/aks/values.aks.yaml

az aks command invoke runs the command from a pod inside the cluster (kubectl + helm preinstalled) and attaches the --file paths — the standard way to operate a private AKS cluster without VNet line-of-sight.

🚨 The master-key caveat

ModelProvider.ApiKey values are encrypted with Ai:KeyProtection:MasterKey. The running deployment already has a master key set out-of-band (not in the chart, not in the KVs above). Therefore:

Quick chooser

You want to… Option Redeploy?
Add/curate models or fix an empty picker now A (mesh nodes) no
Make a model the org-wide default + agent tier B (Helm config) yes
Keep the key out of git C (Key Vault + CSI) yes
Let users use Claude on their own subscription D (Claude Code) code → E
Ship a .cs change E (merge to main → CI image → self-update, or command invoke) yes
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.