Reproduce the cloud locally (k3d + ArgoCD)
The k3d + ArgoCD cluster is the blessed local dev topology (memql#2061,
Epic 0 -- Argo parity): boot it with make up. It mirrors the cloud cluster
(AKS, deploy/k8s/) end to end -- the same Kustomize base, the same
ArgoCD-reconciled manifests, the same ignoreDifferences/selfHeal config -- so
the full class of GitOps + cross-node mesh bugs reproduces locally instead of
only after a deploy.
MemQL ships ONE installation shape (epic memql#3943). There is no staging-versus-production dimension inside the product: an operator who wants a second environment installs a second instance, with its own domain and its own ArgoCD. Local and cloud are two INSTALLS of the same system, not two environments of one, which is why the only differences enumerated below are resource sizes and the sources of DNS, TLS and secrets.
Development principle: test across multiple replicas. Scale the local mesh for cross-node validation; the local overlay reduces several services to one replica to fit developer machines. Never assume a single process. State/context/events that cross a node boundary need explicit plumbing (proxied/forwarded requests don't carry another node's session state; cross-node events need a routing rule). Implement AND test for the hop: a green single-node unit test is a false signal -- exercise the proxied/cross-node path (
test/clustere2e/,component/grpc/ai_forward_test.go) and verify on this cluster. See the "Multi-node is the DEFAULT" rule in the rootCLAUDE.md. (Bugs this would have caught: memql#1448, #1412, #1388.)
What "parity" means here
| Aspect | Local cluster | Cloud cluster | Parity |
|---|---|---|---|
| Orchestrator | ArgoCD (k3d) | ArgoCD (AKS) | identical |
| Manifests | deploy/k8s/overlays/local/ | deploy/k8s/overlays/cloud/ | same base, values differ |
| Node-type split | identity / bff / mcp / agent / planner / workbench / edge | same node roles | same engine; product DSL and clients come from downstream repositories |
| Build model | engine (Dockerfile) for ALL node types (memql-<type>:local), product-agnostic | same product-agnostic engine images | identical -- a product's DSL mounts at runtime via MEMQL_DSL_PATH (the dsl-bundle component), not a per-product image; see downstream-stacks.md |
| Replicas per mesh node (default) | 1 (scale to 2 with make scale N=2) | 2; scaled to 0 when idle | equivalent once scaled -- the saving is the idle time, not the width |
| Per-replica node id | fieldRef: metadata.name (downward API, same as the cloud) | fieldRef: metadata.name | identical |
MEMQL_NODE_ID uniqueness | enforced by fieldRef -- unique per pod | enforced by fieldRef | identical |
ArgoCD ignoreDifferences | /spec/replicas excluded | same | identical |
| Database | CloudNativePG Cluster (memql-db), 1 instance, WAL + base backups to Azurite | CloudNativePG Cluster (memql-db), 3 instances across 3 zones, WAL + base backups to Azure Blob | config only -- same operator + manifests since memql#3846; only instances/sizes/backup destination differ |
| Connection pooler | not present locally (single db-pool pod, direct) | not present -- both DSNs point at memql-db-rw directly; a PgBouncer Pooler ships ready but not enabled (cnpg-db/optional/pooler) | config only |
| Blob storage | Azurite emulator | Azure Blob | config only |
| Secrets / keys | dev defaults (seeded by make secrets) | Key Vault via ESO | config only |
| ExternalSecrets | deleted by $patch: delete in local overlay | present | config only |
| Ingress | k3s-bundled traefik front door (identity, api, mcp, and os under memql.localhost, mkcert TLS) | ingress-nginx | divergent -- traefik vs nginx |
| Digest-pinning gate | skipped for the local overlay in scripts/deploy/drift-check.sh (check_rendered's ENV=local branch, which still asserts the overlay renders) | enforced | divergent -- justified |
Prerequisites
-
docker -- Docker Desktop or Colima (must be running).
-
k3d --
brew install k3d. -
kubectl --
brew install kubectl. -
mkcert --
brew install mkcert. The front door terminates TLS with a browser-trusted*.memql.localhostwildcard;make secretsissues that pair for you (see Front-door TLS below), but it needs mkcert and a root CA on the machine. -
certutil --
sudo apt-get install -y libnss3-toolson Debian/Ubuntu,brew install nsson macOS (Firefox's trust store is NSS on both; Safari and Chrome read the system keychain and need nothing extra).scripts/install/nss-tools.shinstalls it for you and picks the right package manager. (nss-toolson Fedora/RHEL,nsson Arch). Firefox and Chrome on Linux read their own NSS trust store rather than the system one, and mkcert can only write it through certutil. Without it mkcert warns and exits 0, so the pair is issued, the cluster comes up, and every browser refuses the front door -- which is why it is now a hard prerequisite (memql#3560). On macOS the system keychain covers Safari and Chrome; only Firefox needs NSS, so it is not required there.Every
make secretschecks for it, not only the first (memql#3730), because every run now re-checks that the front-door pair covers the domain. On a machine with no browser at all -- a headless box, a CI runner -- waive browser trust instead of installing it:shellMEMQL_LOCAL_TLS_ALLOW_MISSING_CERTUTIL=1 make secretsThe front door then works for
curl, the SDKs and the Cockpit, and stays untrusted in Firefox and Chrome.--allow-missing-certutilis the same waiver when callingscripts/k3d/seed-secrets.shdirectly. -
git -- the cluster's ArgoCD Application points at the current git branch; you must push your branch before ArgoCD can sync it.
Front-door TLS
One-time, per machine -- creating a root CA writes to the system trust store, so it stays a deliberate step and never a prompt:
bash scripts/install/mkcert-setup.sh --confirm=install-memql-caThe confirmation phrase is only needed if you have no mkcert CA yet. An
existing CA is never regenerated -- it may be signing certificates for your
other local stacks -- but mkcert -install IS run against it, because a CA on
disk is not evidence that anything trusts it. mkcert creates the CA first and
trusts it second, so an interrupted first run leaves a real CA that no browser
accepts; the run that follows completes the trust rather than reading the
file's presence as proof the work is done (memql#3560).
Trusting a CA writes to the system trust store, which needs a password. Run this in a terminal: from a process with no terminal attached -- a VS Code extension host, a CI step -- sudo has nowhere to prompt, and the capability refuses with exit 4 and the exact command to run instead.
After that, make up / make secrets issue the *.memql.localhost
pair at ~/.memql/certs/dev.{crt,key} when it is absent, reuse it when it is
present and covers the domain being served, and load it into the cluster as
the memql-front-door-tls Secret that both front-door ingresses reference.
Override the location with MEMQL_LOCAL_TLS_CERT / MEMQL_LOCAL_TLS_KEY (or
--tls-cert / --tls-key).
A pair that does not cover the domain is REISSUED -- with a warning naming
both the names it carried and the names it needed, and
frontDoorTlsSource: reissued in the result envelope (memql#3730). Reuse was
previously conditional on the file merely EXISTING, so a machine that ran the
local stack before the domain rename (memql#3593) kept seeding a valid
certificate for a domain that no longer exists: traefik had nothing matching the
requested SNI, served its own default certificate instead, and make secrets
reported ok: true over it -- permanently, because re-running it changed
nothing. A reissue overwrites the pair in place, so a hand-made certificate you
want kept belongs somewhere MEMQL_LOCAL_TLS_CERT points at, and must cover
*.<domain> and <domain>. A re-run over a pair that already covers them never
rotates it; deliberate reissue of a matching pair is mkcert-setup.sh --force.
Which domain it is checked against is, in order: --domain / make secrets DOMAIN=..., then the domain this cluster already serves (the memql-domain
ConfigMap), then memql.localhost. The cluster tier matters on a custom-domain
cluster: without it, a plain make secrets would check the pair against
memql.localhost and reissue over your lab.example.com certificate.
frontDoorTlsCoverageVerified in the envelope says whether the names were
actually read. Without openssl they cannot be, and the pair is then kept
untouched and reported as unverified rather than as covering -- the one
outcome where a run cannot rule out serving the wrong certificate.
The bring-up asserts that memql-front-door-tls exists AND that the certificate
inside it covers the front-door hostnames, and FAILS on either (memql#3384,
memql#3730). It has to: traefik answers a missing referenced secret -- or one
holding a certificate for names it was not asked for -- by silently serving its
own TRAEFIK DEFAULT CERT, so every TLS client sees an untrusted edge while the
whole mesh reports Available. Without openssl the certificate cannot be read;
the check then says the names were not verified rather than claiming coverage it
did not establish.
Non-browser clients need the CA too. Node (the VS Code extension host, npm tooling) does not read the OS trust store: point it at mkcert's root with
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"otherwise the connection fails with unable to verify the first certificate.
No product sibling repo is required: the engine cluster builds every node
image from this repo's Dockerfile, product-agnostic. A product layers in at
runtime by mounting its DSL bundle at MEMQL_DSL_PATH (the dsl-bundle
component) -- there are no per-product node images (see
downstream-stacks.md).
No env file is required for make up; dev secrets are hardcoded
in scripts/k3d/seed-secrets.sh (Azurite well-known key, memql_dev Postgres
password).
Bring it up
# Single-node (fast, default):make up # Multi-node (2 servers + 1 agent, for cross-node mesh testing):make up SERVERS=2 AGENTS=1make scale N=2 # Clean slate (nuke + repave -- wipes the in-cluster DB by construction):make up-refreshmake up is the full fresh bring-up (scripts/k3d/bringup.sh); it does the
following in order:
- Creates a k3d cluster (default name
memql). - Installs ArgoCD v2.13.3 (same version as the cloud) via
kubectl apply -k deploy/argocd/bootstrap. - Seeds k8s Secrets (
memql-secrets,memql-db-app-creds) viascripts/k3d/seed-secrets.sh. - Applies the ArgoCD Application
memql-localpointing atdeploy/k8s/overlays/localon the current git branch. - Waits for ArgoCD to sync and pods to become Ready (configurable via
MEMQL_K3D_ARGOCD_TIMEOUT, default 300s). - Builds + imports the engine images (same as
make dev) so no pod sits in ImagePullBackOff, then waits for every Deployment to become Available.
make up-refresh runs the same bring-up preceded by a purge teardown
(make down PURGE=1), so the in-cluster Postgres is wiped and the
environment repaves from scratch. It is idempotent and honors the same
SERVERS/AGENTS/REVISION overrides as make up.
Choosing a domain
The cluster is served at memql.localhost by default -- an RFC 6761 loopback
name that needs no domain ownership, no DNS provider and no third party. Bring
your own instead with DOMAIN=:
make up DOMAIN=lab.example.comEverything domain-shaped follows from that one value. It is seeded into the
cluster as the single MEMQL_DOMAIN key of the memql-domain ConfigMap, and
every node derives its identity base URL, expected issuer, discovery endpoint,
CORS origins and OAuth redirect URIs from it at boot
(component/envregistry/domain.go). When the domain differs from the overlay's
committed default, k3d.up also emits two spec.source.kustomize.patches
entries on the ArgoCD Application to repoint the two front-door Ingress hosts.
No file under deploy/ names a domain.
The hosts file is only written when it is needed
The installer points the front-door hostnames at 127.0.0.1 -- unless they
already resolve there. If your own DNS answers 127.0.0.1 for
api.<domain>, identity.<domain>, mcp.<domain>, os.<domain> and the
apex, the hosts block is skipped entirely and no elevation prompt appears. A
hostname resolving to some OTHER address is refused rather than shadowed,
naming the address it answered.
Note that a resolver honouring RFC 6761 -- systemd-resolved does -- already
answers every *.localhost name on loopback, so on those machines the default
domain needs no hosts entry at all.
The domain is chosen once
It is baked into every passkey (the WebAuthn RP id is derived from it), every
session and node token (the issuer is https://identity.<domain>), the
certificate's SANs and the hosts block. make up DOMAIN=... refuses a value
that differs from the one the cluster is already serving; rebuild with
make up-refresh DOMAIN=....
If a node will not start
CreateContainerConfigError naming memql-domain means the ConfigMap is
missing. Re-run make secrets.
The reference is deliberately not optional. A node with no domain would fall back to the base manifests' placeholder issuer, boot, form a mesh, and reject every token with an error naming neither the domain nor the ConfigMap -- so refusing to start is the honest failure.
Port reference
After make up, these host ports are mapped to the k3d cluster:
| Port | Service |
|---|---|
443 | the front door (traefik: TLS terminated here, routed by hostname) |
80 | the front door (redirects) |
5432 | Postgres -- debug only, never a connection path |
The identity (8085) mapping is gone: it was a second entrance to a service
the front door already serves, which is what
environment-parity.md forbids.
The engine overlay includes the product-neutral bff API head and MemQL OS,
served by the edge at https://os.memql.localhost/. Product-specific clients
and DSL bundles still belong to their own downstream overlays. Clients connect
to the engine through api.memql.localhost (TLS on 443, h2c gRPC from traefik
to svc/bff:50051); no port-forward is in the normal connection path. See
environment parity. For
low-level gRPC debugging only, a raw port-forward is still available:
kubectl port-forward -n memql svc/bff 50051:50051 # debug only; not the connection pathAccess identity: https://identity.memql.localhost (front-door TLS -- needs a
*.memql.localhost hosts entry; the cert is issued and seeded by make up /
make secrets, see Front-door TLS). The front door is the
ONLY entrance; for low-level debugging a raw
kubectl port-forward -n memql svc/identity 8085:8085 still works.
Access the engine gRPC head: localhost:50051 (after the port-forward above)
Inner-loop dev
The workflow after a code change:
# Rebuild ALL nodes and restart pods:make dev # Rebuild a single node type (faster):make dev NODE=bffmake dev NODE=identitymake dev NODE=agent # Pull and import upstream infra images (postgres/azurite):make dev PULL_INFRA=1make dev does:
docker buildthe node image from this repo's Dockerfile (BUILD_TAGS=<type>), product-agnostic; a product delivers its DSL at runtime viaMEMQL_DSL_PATH(thedsl-bundlecomponent), not by rebuilding the image.k3d image import-- loads the image into the cluster's containerd.kubectl rollout restart deployment/<node>-- triggers a pod roll so the new image is used. ArgoCD'signoreDifferencesdoes not cover therestartedAtannotation so selfHeal won't revert this.
The inner loop is pure-Argo: no manifest files are applied directly. The pod restart is purely at the pod level; ArgoCD still owns the Deployment spec.
Rebuilding a wizard-installed cluster
A cluster the install wizard created runs RELEASED images: k3d.up --image-registry/--image-tag writes spec.source.kustomize.images overrides
onto the ArgoCD Application, pinning every node to a registry tag. So a plain
rebuild there builds and imports images nothing references, and the checkout
the install cloned stays inert. Two params on the same k3d.dev capability
close that gap:
| Flag | What it names |
|---|---|
--repo-root=<checkout> | the checkout the images are built from. Mirrors k3d.up --repo-root, and exists for the same reason: the packaged editor extension runs a staged copy of scripts/ with no Go source beside it, so "this script's own repository" is not a MemQL tree there. |
--image-source=checkout | after the images are imported, remove the image override of every node this run built so the overlay's own :local references apply to them. ArgoCD's resulting sync is what rolls the pods. |
The extension's Rebuild from checkout is exactly
k3d.dev --repo-root=<checkout> --image-source=checkout, driven by the one-step
graph scripts/install/graph/rebuild.json.
The database operand's override is kept. memql-db is not a node: it is
versioned on the PostgreSQL axis, and CloudNativePG refuses an imageName whose
tag it cannot parse (memql#4063), so the rebuild leaves it pinned and skips
building it.
So does every node you did not rebuild. With NODE=bff only bff's override
is dropped; the other eight keep pointing at their released images, which are
the images actually in the cluster. Dropping theirs would aim them at a
memql-<node>:local this run never imported -- and under
imagePullPolicy: IfNotPresent that is ImagePullBackOff, on nodes you never
asked to touch.
It fails rather than flattering you. An --app-name no Application answers
to is refused (exit 4) instead of being read as "there were no overrides to
drop", and after patching, the run waits for every Deployment to actually name
memql-<node>:local (exit 5 if it never does) -- because ArgoCD's Synced is
bookkeeping about a comparison it has already made, and can be a stale read
taken before the refresh landed.
The change is not permanent. An install, upgrade or repair rewrites those
overrides, which returns the cluster to released images -- rebuild again to go
back to the checkout's. Without --image-source=checkout nothing is patched, so
make dev behaves exactly as it always has.
...and bringing that checkout up to date first
Update from origin and rebuild sits beside it and is the same crossing with
a fetch in front: install.updateStack over the recorded checkout, then the
identical rebuild step, driven by scripts/install/graph/update-rebuild.json.
Both buttons stay, because they answer two different questions -- "test just
what I have" and "test the latest with what I have" -- and neither is a mode of
the other.
Building node images from a checkout runs the CHECKOUT's scripts, not the
extension's (memql#5056, corrected by memql#5064). A packaged extension carries
a staged copy of scripts/ so an install can run before any checkout exists;
once a checkout is the thing being BUILT, it supplies the build recipe as well as
the source. Anything else is a recipe frozen at the extension's package date
driving a tree that has since moved -- which is how an update once asked for a
voice node and a voice-runtime Dockerfile stage that had both been retired in
the commits it had just pulled.
The rule is keyed on the capability, k3d.dev, not on which button was
pressed. Three step ids reach it -- rebuildFromCheckout on the two rebuild
graphs and buildImages on the from-source install -- and naming the flows
instead covered only the first two, so a fresh main install kept failing on the
retired node for a release longer. It is also resolved PER STEP rather than once
per run: an install's checkout is produced by stackCheckout several waves in,
so a session-time answer sees no checkout at all and silently keeps the frozen
scripts.
A checkout too old to carry scripts/lib/capability.sh falls back to the staged
copy, since it could not have run these scripts either way. The graph document
still comes from the extension: it names the steps, and the wizard is written
against them. clusterUp (k3d.up) is deliberately NOT included -- it is
reachable from the release lane against an arbitrary older tag, and a capability
script refuses an undeclared flag outright, so that one is a compatibility
question of its own.
A failed rebuild imports nothing (memql#5058). Every image is built first
and only then imported, so a build that fails partway leaves the cluster exactly
as it was. It used to build-and-import one node at a time, which meant a failure
on the third node left the first two with new image content under an unchanged
:local tag and no rollout -- invisible to kubectl get pods, recorded nowhere,
and waiting to move those two nodes onto different code at their next restart.
A refusal changes nothing on disk. That is the property the whole thing is arranged around, because the person who presses this is a developer holding uncommitted work:
| What it finds | What it does |
|---|---|
| behind, and your edits do not touch what is arriving | fast-forwards; your edits come along |
| your edits touch files the update also changes | stops, names the files, changes nothing |
| commits here the branch does not have | stops and says so -- or combines them, if you chose that |
| conflicts while combining | stops, names the paths, and leaves the conflict in the editor to resolve |
| a merge or rebase already under way | stops before it fetches |
The safety is git's rather than ours: git merge --ff-only and git checkout --detach both carry uncommitted edits across and both refuse atomically. What
the script adds is computing the overlap first, so the refusal names files and
the checklist can predict it instead of reporting it afterwards.
A wizard-installed checkout is cloned --depth 1, and the first update
deepens it. At depth 1 the local commit and a freshly fetched tip share no
ancestry in the object store, so git cannot see a fast-forward and every update
would report a divergence that is not real. It happens once per checkout and
the checklist says so before you start.
A release install's checkout is detached at a tag and has no branch to
update to. That is the honest answer rather than a gap: the checklist says so
and offers no branch of its own, because substituting main would quietly
offer to move a pinned cluster onto the tip of a branch nobody named. A main
install repaired since is detached too, and there the branch the install
recorded is used.
Multi-node mesh testing
# Scale to 2 replicas per Deployment:make scale N=2 # Litmus: unique MEMQL_NODE_ID per pod + one shared identity signing keyset:make status # Scale back to single-node:make scale N=1Because deploy/k8s/base/ sets MEMQL_NODE_ID via fieldRef: metadata.name,
each pod automatically gets a unique node id matching its pod name. No overlay
changes are needed to enable multi-node.
make status asserts two properties, both of which only exist once there is
more than one replica:
- Unique
MEMQL_NODE_IDper pod. Shared ids are the root cause of the #1042 class of mesh bugs. - One shared identity signing keyset. It reads
/.well-known/jwks.jsonfrom every identity replica (through the apiserver pod proxy -- engine images areFROM scratch, so there is no shell to exec into) and compares thekidsets. Divergent keysets mean each replica has minted its OWN Ed25519 key, so a token issued by one is unverifiable by any node that fetched JWKS from the other: roughly half of all auth fails, and the rejection reads as an authentication error rather than a key problem. That is memql#3400 --make scale N=2walked straight into it beforemake secretsstarted seeding a sharedMEMQL_IDENTITY_SIGNING_KEY_B64.
A replica the litmus could not read is reported UNKNOWN, never as a pass.
Re-seed secrets
make secretsThis re-runs scripts/k3d/seed-secrets.sh and is idempotent -- including for
the two values a re-run must never silently rotate: MEMQL_MASTER_KEY
(memql#2958) and the identity signing seed MEMQL_IDENTITY_SIGNING_KEY_B64
(memql#3400, whose rotation would invalidate every live session and every
minted mesh node token). Both are read back from the cluster and preserved.
Use it if you've
torn down and recreated the cluster, or if you've rotated the dev secret values.
Database: backups and a restore drill (local)
The local database is a CloudNativePG Cluster (memql-db), not a bare
postgres pod — the same operator, the same operand image and the same four
resource kinds the cloud runs, with smaller numbers in them
(memql#3846). It archives WAL and takes base backups to the in-cluster
Azurite, so the backup path is exercised locally rather than only in the
cloud.
make status # includes the database litmus: phase, extensions, archivingThe litmus reports three things a pod listing cannot distinguish:
| Line | Why it is separate |
|---|---|
cluster: / phase: | is Postgres serving, and which pod is primary |
extensions: | a Cluster reports Ready as soon as Postgres accepts connections — before the Database CR reconciles timescaledb and vector. A Ready cluster with no timescaledb looks healthy and fails the first create_hypertable. |
archiving: | the one that fails silently — the database serves traffic perfectly with no backups behind it |
Take a backup
kubectl apply -n memql -f - <<'YAML'apiVersion: postgresql.cnpg.io/v1kind: Backupmetadata: { name: manual-backup }spec: cluster: { name: memql-db } method: plugin pluginConfiguration: name: barman-cloud.cloudnative-pg.io parameters: { barmanObjectName: memql-db-backup }YAML kubectl get backup manual-backup -n memql -w # -> phase: completedA nightly ScheduledBackup runs the same path unattended.
Restore into a scratch Cluster
Recovery is a new Cluster bootstrapped from the object store — never an in-place operation on the running one:
kubectl apply -n memql -f - <<'YAML'apiVersion: postgresql.cnpg.io/v1kind: Clustermetadata: { name: memql-db-scratch }spec: instances: 1 imageName: memql-db:16-dev imagePullPolicy: IfNotPresent enablePDB: false postgresql: shared_preload_libraries: [timescaledb] storage: { size: 2Gi } bootstrap: recovery: source: origin externalClusters: - name: origin plugin: name: barman-cloud.cloudnative-pg.io parameters: barmanObjectName: memql-db-backup serverName: memql-dbYAML kubectl wait --for=condition=Ready cluster/memql-db-scratch -n memql --timeout=480skubectl exec -n memql memql-db-scratch-1 -c postgres -- \ psql -U postgres -d memql -c "SELECT count(*) FROM timescaledb_information.hypertables;"kubectl delete cluster memql-db-scratch -n memql # tear the scratch copy downAdd bootstrap.recovery.recoveryTarget.targetTime: "<RFC3339>" to restore to a
point in time rather than to the latest backup.
Two local-only settings make this work, and both look like noise. The
ObjectStore'sdestinationPathcarries the account as the first path segment (http://azurite:10000/devstoreaccount1/...), where the cloud form carries it in the hostname (https://<account>.blob.core.windows.net/<container>/) — host first in both, never a container-onlyazure://<container>/shape (memql#4496). And the azurite Deployment passes--skipApiVersionCheck, because Azurite refuses thex-ms-versionbarman's SDK sends. Without either one there are simply no backups, and the Cluster still reports Ready — which is whyTestLocalBackupsCanActuallyReachAzuriteasserts both.
Tear down
make down # delete cluster (keeps kubeconfig context)make down PURGE=1 # also remove the kubeconfig contextConfig-vs-topology audit
The governing invariant: along the mesh-delivery path, only config may differ from the cloud -- never topology or build. Every divergence below is enumerated with its justification.
Invariants -- MUST stay identical
- Service set: identity / bff / mcp / agent / planner / workbench / edge. The bff is product-neutral; product DSL bundles and clients are downstream. MemQL OS is the platform client included with the engine.
- Build source per node: every node is the same product-agnostic engine
image (built here from this repo's Dockerfile; digest-pinned in the cloud
overlay) -- local and cloud never diverge on build. Only the DSL bundle mounted at
runtime (
MEMQL_DSL_PATH) differs per product (the #1053 rule, revised under platform consolidation #2472). fieldRef: metadata.nameforMEMQL_NODE_IDon every Deployment indeploy/k8s/base/-- identical to the cloud.- ArgoCD
ignoreDifferenceson/spec/replicas-- identical to the cloud. - Inter-node addressing (
MEMQL_NODE_ADDRESS/MEMQL_PARENT_ADDRESS/MEMQL_WORKER_PEERS/MEMQL_WORKBENCH_REMOTE) -- via k8s Service DNS (same as the cloud, just cluster-local).
Divergences -- justified
| # | Divergence | Local | Cloud | Why acceptable |
|---|---|---|---|---|
| 1 | Replicas (default) | 1 per Deployment | 2 per Deployment (0 when idle) | Resource-constrained laptops locally; cost in the cloud, which parks at zero between uses. Multi-node is opt-in in BOTH via make scale N=2. The fieldRef mechanism is identical everywhere, so the multi-node path fully reproduces wherever you scale it up. |
| 2 | Ingress | k3s-bundled traefik HTTPS front doors for identity, API, MCP, and OS (mkcert TLS) | ingress-nginx on AKS | Same hostname-based ingress topology as cloud; traefik ships with k3s. Raw port-forwards are available for debugging, not normal client connection. |
| 3 | Digest-pinning gate | skipped for ENV=local in scripts/deploy/drift-check.sh | enforced | Local images are built by make dev with a stable :local tag; they have no ACR digest. check_rendered special-cases ENV=local: it skips the digest-pin assertion but still fails if the overlay does not render. As of this writing no Go test asserts this exemption specifically -- scripts/deploy/drift_check_test.go covers image-ref normalization, not the local-skip branch -- so the behaviour is enforced by the script alone. |
| 4 | ExternalSecrets / Key Vault | deleted by $patch: delete in local overlay | ESO syncs from Key Vault | Dev secrets are seeded directly by make secrets. |
| 5 | Connection pooler | direct Postgres connection | direct Postgres connection (a PgBouncer Pooler ships ready but not enabled, cnpg-db/optional/pooler) | Single-node dev without a pool is safe; the cloud runs without one today too, and the optional pooler can be enabled on either side if a connection-count ceiling ever demands it. |
Config-only -- EXPECTED to differ
MEMQL_DATABASE_DSN(localmemql-db-rwvs cloudmemql-db-rw, self-hosted CloudNativePG on both sides).- Blob backend (Azurite connection string vs Azure Blob).
- Bootstrap/dev escape hatches (
MEMQL_IDENTITY_ALLOW_INSECURE_*). MEMQL_IDENTITY_BASE_URL/MEMQL_IDENTITY_VERIFIER_EXPECTED_ISSUER(local HTTPS domain vs the cloud installation's HTTPS domain).
Worked example: reproduce a cross-node mesh bug
make up SERVERS=2 AGENTS=1 && make scale N=2.make status-- verify all pods show distinctMEMQL_NODE_IDvalues. If any share an id, stop: the mesh cannot reproduce cross-node bugs.- Reproduce the scenario (e.g. send a chat message that triggers an assistant
reply). Watch logs:
shellkubectl logs -n memql -l app=agent --all-containers -f | grep -Ei 'node_id|EventForward|dedup'
- Root-cause against the cross-node path (event routing rules, session state on the wrong node, missing proxy forward).
- Fix and
make devto rebuild + restart. ArgoCD reconciles; the pod roll picks up the new image.
The previous Compose-based local stack is retired
The k3d + ArgoCD topology is the only supported local path as of
memql#2061 (Epic 0). The earlier Compose-based local stack is fully retired
(memql#2068 / #2088) -- the old compose files and their make targets are
deleted, replaced by make up / make dev / make down. k3d + ArgoCD wins
because:
- It uses the same manifests and reconciliation path as the cloud (Kustomize + ArgoCD), so GitOps bugs reproduce locally.
fieldRef: metadata.nameforMEMQL_NODE_IDis identical to the cloud.- The ArgoCD
ignoreDifferencesand selfHeal behavior matches the cloud exactly.
There is no nginx front door locally, but the local overlay DOES ship a
k3s-bundled traefik front door on 443 for https://identity.memql.localhost
(deploy/k8s/overlays/local/front-door.yaml), terminating TLS with a
browser-trusted mkcert *.memql.localhost wildcard (memql-front-door-tls, issued and
seeded by make secrets at ~/.memql/certs/dev.{crt,key} -- see
Front-door TLS; its absence fails the bring-up rather than
degrading silently). This mirrors the cloud ingress topology. The gRPC heads
are still reached via kubectl port-forward -- identity is not exposed on gRPC
externally, and the mcp engine gRPC head :50051 is forwarded on demand.
Postgres :5432 is likewise port-forwarded; the product SPA + the product
bff (a plain engine node fronting the product's DSL bundle) are
engine-external and absent locally (#2204).