Files
lmxopcua/docker-dev/docker-compose.yml
T

988 lines
65 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# docker-dev/ — Mac-friendly THREE-mesh hub-and-spoke fleet for v2 development + manual UI exercise.
#
# Topology (per-cluster mesh Phase 6, 2026-07-24): THREE independent 2-node Akka
# meshes, not one six-node mesh. Every node still runs the same ActorSystem name
# `otopcua` and all six containers still sit on the ONE shared docker-dev network
# — separation is purely by per-pair self-first Cluster__SeedNodes partitioning,
# never by network isolation. This is deliberately faithful to production, where
# the three meshes are separated by a WAN, not a firewall: nothing here stops
# central-1 opening a bare TCP connection to site-a-1's Akka port, it just never
# tries, because no seed list and no gossip ever names it.
#
# MAIN central-1 + central-2 (fused admin+driver, roles admin,driver,cluster-MAIN)
# SITE-A site-a-1 + site-a-2 (driver-only, roles driver,cluster-SITE-A)
# SITE-B site-b-1 + site-b-2 (driver-only, roles driver,cluster-SITE-B)
#
# Each pair seeds ONLY itself (self-first, partner-second) — no node lists a node
# outside its own mesh as a seed. Logical/tenant separation (ServerCluster.ClusterId
# rows MAIN / SITE-A / SITE-B in the one shared `OtOpcUa` ConfigDb) now COINCIDES
# with mesh separation: a `cluster-<ClusterId>` Akka role marks which mesh + which
# ServerCluster row a node belongs to (RoleParser.ClusterRolePrefix = "cluster-").
#
# Since central no longer shares a gossip ring with the site meshes, it reaches
# them over the three explicit split-topology transports instead of gossip:
# - Command plane: MeshTransport__Mode=Grpc (central dials each site node's
# NodeCommandService on :4056; a node posts its deploy ack
# back to central's CentralCommandService on :4055. DPS is
# gossip-only and would not cross the mesh boundary at all)
# - Live telemetry: Telemetry__Mode=Grpc on every driver node (all six) +
# TelemetryDial__Mode=Grpc on the admin pair (central-1/2)
# — the site node hosts the gRPC server, central dials in
# - Deployed config: ConfigSource__Mode=FetchAndCache on the site nodes (already
# true since Phase 3/4 — driver-only nodes have no ConfigDb
# connection at all); central stays ConfigSource-less/Direct
# and keeps serving via ConfigServe.
# SplitTopologyTransportValidator (Cluster project) REFUSES to start any node
# carrying a `cluster-*` role that is left on a DPS-shaped transport — these
# three flips are therefore mandatory here, not optional dark switches.
#
# Stack:
# sql SQL Server 2022 — hosts the one ConfigDb every node uses
# cluster-seed one-shot mssql-tools job that INSERTs the ServerCluster +
# ClusterNode rows scoping each tenant, then exits (idempotent)
#
# central-1, central-2 OTOPCUA_ROLES=admin,driver,cluster-MAIN — the ONLY UI +
# deploy singleton, plus the MAIN cluster's OPC UA
# publishers. Reachable at http://localhost:9200 (via
# Traefik). Both are Akka seed nodes for the MAIN mesh
# ONLY, each listing ITSELF first (2026-07-22) so either
# can cold-start while the other is down.
# site-a-1, site-a-2 OTOPCUA_ROLES=driver,cluster-SITE-A — driver-only.
# site-a-2 additionally pins a miscased Cluster__Roles__0=
# "Driver" (R3-04, archreview 01/C-9 standing pin).
# site-b-1, site-b-2 OTOPCUA_ROLES=driver,cluster-SITE-B — members of their
# OWN 2-node mesh (self-first seeded within the pair, NOT
# off central) — EXCEPT site-b-2, which sets NO OTOPCUA_ROLES
# at all and is Cluster:Roles-only (R3-04, archreview 01/S-12
# standing pin — the rig's one node that can exhibit the
# two-role-authority split). They serve no UI, but they DO expose OPC UA
# data-plane endpoints (4842-4845) and authenticate those
# users against the same shared GLAuth (via the *ldap-env
# anchor); central manages + deploys to them over the
# Grpc/FetchAndCache transports above, not gossip.
#
# Auth is real LDAP against the shared GLAuth on the Linux Docker host
# (10.100.0.35:3893, dc=zb,dc=local) — there is no LDAP container here.
# EVERY host node carries the Security__Ldap__* block (shared *ldap-env anchor):
# the driver-only site nodes serve OPC UA endpoints too, and LDAP options are
# validated at boot on all of them (a node with no LDAP config crashes with
# OptionsValidationException — transport None + AllowInsecure false).
# Sign in `multi-role` / `password`.
#
# traefik PathPrefix(`/`) → central-1 / central-2 (the single UI route).
#
# OPC UA endpoints (host-side port → container 4840):
# central-1 :4840 central-2 :4841
# site-a-1 :4842 site-a-2 :4843
# site-b-1 :4844 site-b-2 :4845
#
# Headless deploy: POST http://localhost:9200/api/deployments with the
# X-Api-Key header (Security__DeployApiKey = "docker-dev-deploy-key").
#
# SQL persistence: the otopcua-mssql-data named volume keeps the ConfigDb schema
# + seeded clusters across `docker compose up` cycles; without it a recreate
# silently drops the OtOpcUa database.
#
# Per-cluster mesh Phase 5 (gRPC live-telemetry stream): every driver-role node (all six,
# including the fused central pair) carries Telemetry__GrpcListenPort=4056 (dedicated h2c
# listener, verified free against every other per-node port: Akka 4053, ConfigServe 4055 on
# central, LocalDb sync 9001 on site-a, OPC UA 4840, HTTP 9000) + Telemetry__ApiKey
# (committed dev key, matches the SQL password / secrets KEK / ConfigServe key exception).
# central-1/central-2 also carry the matching TelemetryDial__ApiKey. As of Phase 6, Telemetry__Mode
# is set to "Grpc" on all six nodes and TelemetryDial__Mode is set to "Grpc" on central-1/central-2
# ONLY — SplitTopologyTransportValidator requires it once a node carries a cluster-* role, so this
# is no longer an optional live-gate step (see the Phase 5 comment history in git blame for the
# prior dark-switch shape). The ClusterNode.GrpcPort column (seeded by
# docker-dev/seed/seed-clusters.sql) points central's dial supervisor at each node's :4056.
#
# Production-faithful auth (R3-01, archreview 03/S14): central-1/central-2's
# Security__Auth__DisableLogin defaults to "true" here (byte-identical to the previous hardcoded
# value) but is now `${OTOPCUA_DISABLE_LOGIN:-true}` — a one-variable flip to the REAL cookie
# login flow instead of an edit-and-remember-to-revert. DisableLogin registers an always-succeeding
# handler UNDER the cookie scheme name, so every live gate run with the default masks the AdminUI
# cookie FallbackPolicy entirely — which is exactly how 03/S14 (the FallbackPolicy blocking every
# mesh gRPC call on a login-enabled admin node) survived seven phases of prior live gates. To drive
# this rig production-faithfully:
# OTOPCUA_DISABLE_LOGIN=false docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2
# Sign in as `admin` / `password` (OtOpcUa-Admins, AdminUI Administrator) or `multi-role` / `password`
# (member of every group) against the shared GLAuth (10.100.0.35:3893, dc=zb,dc=local). The old
# `alice`/`bob` users no longer exist there. NOTE: the serviceaccount bind password below is the
# pre-rotation value (rotated out-of-band, SEC-36, scadaproj/infra/glauth/config.toml); a real-login run
# needs the current one supplied via the environment. The data plane no longer uses LDAP at all (API keys). Restore the default with `docker compose ... up -d central-1 central-2` (no env
# var). See docs/security.md "Mesh gRPC ingress is AllowAnonymous by design" for what this actually
# exercises.
#
# Usage:
# docker compose -f docker-dev/docker-compose.yml up -d --build
# open http://localhost:9200 # central Blazor admin UI
# open http://localhost:8089 # Traefik dashboard (8080 is the sister scadalink stack)
#
# Tear-down: docker compose -f docker-dev/docker-compose.yml down -v
name: otopcua-dev
# ── Shared secret-replication env (all six host nodes) ──────────────────────────
# Akka peer-to-peer clustered secret replication rides the existing `otopcua` Akka
# mesh (DistributedPubSub topic `zb-mom-ww-secrets`) across all six host nodes. Two
# hard requirements are wired here, identically, on every node:
# 1. Secrets__Replication__Enabled=true → SecretsRegistration.AddOtOpcUaSecrets
# switches from plain AddZbSecrets to AddZbSecretsAkkaReplication + the eager
# SecretReplicationStarter hosted service, so every node joins anti-entropy.
# 2. ZB_SECRETS_MASTER_KEY → the ONE shared 32-byte base64 KEK. Every node MUST
# carry the SAME key or peers fail closed decrypting each other's rows
# (kek_id mismatch). appsettings.json binds Secrets:MasterKey:Source=Environment
# / EnvVarName=ZB_SECRETS_MASTER_KEY, so this env var IS the KEK on every node.
# The KEK below is a committed DEV-ONLY default (matches the rig's zero-operator-step
# philosophy, like the committed dev SQL password). Override for a fresh key with:
# OTOPCUA_SECRETS_KEK=$(openssl rand -base64 32) docker compose ... up -d
# NEVER reuse this key outside this local dev rig.
# AnnounceInterval is shortened to 5s (default 30s) so anti-entropy convergence is
# observable quickly during dev exercise. Each node keeps its OWN local SQLite store;
# replication syncs them.
#
# SqlitePath is overridden onto the node's existing per-node volume instead of the
# in-container default. Two things a container-layer path costs, both of which bit the
# 2026-08-07 pair-local gate: the store is DESTROYED by any `up -d --build` container
# recreate (so a seeded row cannot outlive a rig rebuild), and it is unreachable from the
# host, so seeding or inspecting a node's store meant stopping the container and
# `docker cp`-ing the file out. On the volume, each node still has a private store (one
# volume per node — this is NOT shared storage, and replication is still what syncs
# them), but it survives recreate and a helper container can mount it read/write while
# the node keeps running.
#
# The path is /data (a SECOND mount of the same per-node volume), not the volume's
# /app/data mount: Secrets 0.6.0 fail-closed validation rejects a store inside the
# application content root (/app) — the rule that exists because a deploy renaming the
# app directory took the MxGateway credential store with it (2026-08-09). Same volume,
# same file at the volume root, different mount point — existing seeded stores carry
# over untouched. LocalDb deliberately stays on /app/data (the rule is secrets-only).
#
# Per-cluster mesh Phase 6: this DPS topic now rides each pair's OWN 2-node mesh, not one
# shared six-node mesh — so secret replication is PAIR-LOCAL. central-1/central-2 replicate
# with each other; site-a-1/site-a-2 replicate with each other; site-b-1/site-b-2 replicate
# with each other. There is no cross-cluster secret sync after the split (by design — the site
# meshes don't share a gossip ring with central or with each other, consistent with Phase 4's
# "driver-only nodes have no SQL, hence no SQL-backed cross-mesh hub" posture). No code change
# was needed for this — Enabled=true + the same KEK on every node is still correct; it just now
# converges within three independent 2-node groups instead of one six-node group.
x-secrets-env: &secrets-env
Secrets__SqlitePath: "/data/otopcua-secrets.db"
Secrets__Replication__Enabled: "true"
Secrets__Replication__AnnounceInterval: "00:00:05"
ZB_SECRETS_MASTER_KEY: "${OTOPCUA_SECRETS_KEK:-ZYGhIX0luS/XsevpCB2W18jYHMcqO6AjM9oXy+T6Zp4=}"
# EVERY node that serves an OPC UA endpoint authenticates data-plane users against the shared GLAuth —
# that includes the driver-only site nodes (ports 4842-4845), not just the admin/central pair. LDAP
# options are validated at host start on ALL of them (Enabled=true by appsettings default, Transport=None,
# AllowInsecure=false ⇒ OptionsValidationException), so a node that carries no LDAP config crashes at boot.
# This anchor is merged into every host node's environment (via `<<: [*secrets-env, *ldap-env]`). Central
# still also lists the block inline for readability; the explicit keys win over the merge, same values.
# #520 — OPC UA data-plane API keys. The pepper is merged into every host node: a driver node needs it
# to VERIFY keys, an admin node to MINT them, and a mismatch between the two is a silent authentication
# failure. Committed dev value only, under the same committed-dev-secret exception as the LDAP service
# account and the deploy key below it; a real deployment supplies Security__ApiKeys__Pepper via env or
# a ${secret:} reference and NEVER commits it. Rotating it invalidates every existing key.
x-apikeys-env: &apikeys-env
Security__ApiKeys__Pepper: "docker-dev-apikey-pepper-not-for-production"
x-ldap-env: &ldap-env
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
Security__Ldap__Server: "10.100.0.35"
Security__Ldap__Port: "3893"
Security__Ldap__Transport: "None"
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
Security__Ldap__GroupToRole__WriteConfigure: "WriteConfigure"
Security__Ldap__GroupToRole__AlarmAck: "AlarmAck"
# Readiness gate for a pair FOUNDER node (site-a-1 / site-b-1): passes once its Akka port 4053 is bound.
# The partner (site-a-2 / site-b-2) waits on this via `depends_on: { condition: service_healthy }` so the
# founder forms its 2-node mesh FIRST and the partner JOINS it — instead of both self-first seeds racing
# and each forming its own single-node cluster (the split brain the Phase 6 live gate observed: 250/250,
# both Primary). `service_started` is NOT enough — it fires the instant the container launches, long
# before Akka binds. The image ships bash but no nc/curl, so the probe uses bash's /dev/tcp. start_period
# gives the founder a head start to not just bind but FORM (a 1-node cluster forms within the same second
# as the bind), so the partner's InitJoin lands on an existing cluster and is ack'd.
x-akka-founder-healthcheck: &akka-founder-healthcheck
test: ["CMD-SHELL", "bash -c '</dev/tcp/localhost/4053' || exit 1"]
interval: 3s
timeout: 2s
retries: 20
start_period: 8s
services:
sql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "OtOpcUa!Dev123"
MSSQL_PID: Developer
ports:
- "14330:1433"
# Persist the ConfigDb across container recreates. Without this the dev SQL
# is ephemeral (container writable layer), so a recreate silently drops the
# OtOpcUa database and every host node fails its configdb health check until
# EF auto-migration + cluster-seed rebuild it. The named volume keeps the
# schema + seeded clusters between `docker compose up` cycles.
volumes:
- otopcua-mssql-data:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'OtOpcUa!Dev123' -No -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 20
# ── Migrator (one-shot) ────────────────────────────────────────────────────
# Applies EF Core migrations to the OtOpcUa ConfigDb so a fresh SQL volume gets
# the schema with no operator step (the host nodes deliberately don't auto-
# migrate — production owns schema changes). cluster-seed + every host node
# depend on this completing, so nothing races an in-progress migration.
# Idempotent: a no-op once the schema is current.
migrator:
build:
context: ..
dockerfile: docker-dev/Dockerfile
target: migrator
image: otopcua-migrator:dev
depends_on:
sql:
condition: service_healthy
environment:
OTOPCUA_CONFIG_CONNECTION: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
restart: "no"
# ── Cluster seed (one-shot) ────────────────────────────────────────────────
# Runs only after `migrator` completes (so the schema is final — no race), then
# INSERTs the three ServerCluster rows and the six ClusterNode rows that scope
# each tenant inside the shared OtOpcUa ConfigDb. Idempotent — re-runs are no-ops.
cluster-seed:
image: mcr.microsoft.com/mssql-tools:latest
depends_on:
migrator:
condition: service_completed_successfully
volumes:
- ./seed:/seed:ro
entrypoint: ["/bin/bash", "/seed/entrypoint.sh"]
restart: "no"
# A local OpenLDAP container used to live here, but the bitnami/openldap:2.6
# image was retired (manifest gone) and bitnamilegacy/openldap:2.6 crashes
# during LDIF setup (exit 68). Rather than stub auth, the central (admin-role)
# containers bind the shared GLAuth on the Linux Docker host (Security__Ldap__*
# below: 10.100.0.35:3893, dc=zb,dc=local, DevStubMode=false) — so dev auth
# exercises the real LDAP bind + group→role path. Sign in `multi-role` /
# `password` (all roles) or any shared test user / `password`.
# ── Central cluster (2-node fused admin+driver) ─────────────────────────────
# The only UI + deploy singleton; also the MAIN cluster's OPC UA publishers.
# Per-cluster mesh Phase 6: central-1 and central-2 form their OWN 2-node "MAIN" Akka mesh,
# NOT a shared mesh with the site nodes — each lists itself first as seed, so either can
# cold-start while the other is down, and the site meshes are never in this pair's seed list.
central-1: &otopcua-host
build:
context: ..
dockerfile: docker-dev/Dockerfile
target: runtime
image: otopcua-host:dev
# Per-node memory bounds. The full single-mesh stack (6 host nodes) OOM-killed
# central-1 on a loaded host. Each host node measured ~357 MiB idle-solo and
# climbs sharply under deploy/materialise load: a node materialising its full
# cluster slice (e.g. central → MAIN's galaxy mirror + UNS overlay, ~1400 OPC UA
# nodes) peaks well above 1g during a deploy — a 1g cap OOM-kills it (exit 137).
# Cap at 2g (≈peak + headroom) with a 1g reservation. These top-level keys are
# inherited by every service that uses `<<: *otopcua-host` (YAML merge keeps the
# anchor's scalar keys; only the `environment` block is re-declared per service).
# The full 6-node mesh needs ~12g of Docker Desktop VM memory — on a constrained
# host raise the VM memory or run fewer host services.
mem_limit: 2g
mem_reservation: 1g
# Restart supervision. A 2-node keep-oldest SBR downs the LONE survivor when the
# OLDEST node crashes (the survivor is "the side without the oldest" → DownReachable
# → run-coordinated-shutdown-when-down terminates it). Recovery is by exit-and-rejoin:
# the supervisor restarts the exited node and it re-forms/rejoins as a fresh
# incarnation (the ScadaBridge pattern). Without a restart policy the exited node
# stays down and the cluster looks like a total outage. Inherited by every host node
# via the anchor merge.
restart: unless-stopped
depends_on:
sql: { condition: service_healthy }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# The Postgres + Oracle counterparts, pointing at the two DISPOSABLE fixture containers on the
# shared docker host (tests/.../Driver.Sql.IntegrationTests/Docker/{postgres,oracle}/). Same
# committed-dev-secret exception; the passwords are each fixture compose's documented default.
# Unlike DevSql these are NOT on the rig's own network, so they carry the host's IP.
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-1"
# Per-cluster mesh Phase 3 (config fetch-and-cache). Central SERVES the deployed-configuration
# artifact to FetchAndCache site nodes over a dedicated h2c gRPC listener (:4055), gated by the
# shared node key. Central itself is ALWAYS Direct — it IS the SQL source and must never fetch.
# ConfigServe:ApiKey must be byte-identical to the site nodes' ConfigSource:ApiKey (fail-closed).
# Binding :4055 activates the dedicated-h2c Kestrel takeover on central (it re-binds :9000 too).
ConfigServe__GrpcListenPort: "4055"
ConfigServe__ApiKey: "configserve-docker-dev-key"
ConfigSource__Mode: "Direct"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream). central-1 carries a cluster-*
# role + admin, so SplitTopologyTransportValidator requires BOTH Telemetry:Mode=Grpc
# (it is also a driver node, publishing MAIN's own telemetry) AND TelemetryDial:Mode=Grpc
# (it is the admin-role dialer for every site node). The dedicated gRPC listener below binds
# regardless of mode (the node "always hosts" per docs/Telemetry.md). Port 4056 checked free
# against every other port this node binds: Akka 4053, ConfigServe 4055, OPC UA 4840
# (container-internal), HTTP 9000. TelemetryDial__ApiKey (below) is central's dial-side key
# and must equal every node's Telemetry:ApiKey (fail-closed interceptor).
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
TelemetryDial__Mode: "Grpc"
TelemetryDial__ApiKey: "telemetry-docker-dev-key"
# Both redundancy peers are seeds (#459 / ScadaBridge parity) so a restarted node can
# re-join the MAIN mesh via EITHER peer — NOT via a site node; the site meshes are never in
# this pair's seed list (per-cluster mesh Phase 6).
#
# ORDER IS LOAD-BEARING (2026-07-22): each node lists ITSELF first. Akka runs
# FirstSeedNodeProcess — the only path that can form a NEW cluster when no peer answers
# InitJoin — exclusively for seed-nodes[0]; every other node retries InitJoin forever. So a
# node listing its partner first cannot cold-start while that partner is down. Enforced at
# boot by AkkaClusterOptionsValidator; see docs/Redundancy.md.
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@central-2:4053"
# Per-cluster mesh Phase 6: cluster-MAIN marks this node as a member of the MAIN mesh
# (RoleParser.ClusterRolePrefix = "cluster-"), read by SplitTopologyTransportValidator to
# require the mesh-crossing Grpc transports, and by ClusterRoleInfo to scope redundancy +
# ClusterNodeAddressReconcilerActor to ClusterId=MAIN.
Cluster__Roles__0: "admin"
Cluster__Roles__1: "driver"
Cluster__Roles__2: "cluster-MAIN"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6). Central no
# longer shares a gossip ring with the site meshes, so DPS (gossip-only) cannot reach them —
# Grpc is the only transport that can. SplitTopologyTransportValidator refuses to start any
# node carrying a cluster-* role that is left on Dps, so this is fixed, not an
# env-var-overridable default, on every node in this file.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport (docs/plans/2026-08-06-clusterclient-to-grpc-design.md). GrpcPsk is
# this node's OWN per-cluster key (MAIN, since central is cluster-MAIN); CentralGrpcEndpoints
# is where a node posts its deploy ack. ClusterPsks is CENTRAL-SIDE ONLY (IMeshPskProvider's
# override map, consulted before the Secrets store) — one entry per cluster central dials, so
# only the central nodes carry it.
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
Security__Ldap__Server: "10.100.0.35"
Security__Ldap__Port: "3893"
Security__Ldap__Transport: "None"
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__DeployApiKey: "docker-dev-deploy-key"
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
# #520 stage 6 — node ACL enforcement, the dark switch. Default OFF, exactly as in production.
# The centrals read the SAME env override as the site-a pair, so an ACL live gate can enable it
# where BoundedApiKeyStore actually lives (only a fused admin+driver node wraps ConfigDbApiKeyStore):
# OTOPCUA_ACL_ENABLED=true docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2
# Added 2026-08-20: the R3-05 gate found this key wired only to site-a-1/2, so that command was a
# silent no-op on the centrals and the gate needed a scratch compose override to proceed.
Security__Acl__Enabled: "${OTOPCUA_ACL_ENABLED:-false}"
# Map the shared-GLAuth data-plane groups (ReadOnly/WriteOperate/...) to data-plane role
# strings the OPC UA session ACL gates read. The DB LdapGroupRoleMapping is AdminRole-typed
# (AdminUI roles); this free-form GroupToRole baseline is the path for data-plane roles.
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
Security__Ldap__GroupToRole__WriteConfigure: "WriteConfigure"
Security__Ldap__GroupToRole__AlarmAck: "AlarmAck"
# Server-side HistoryRead via the ZB.MOM.WW.HistorianGateway sidecar. DISABLED by default: the
# docker-dev rig has no historian gateway to point at, and the historian-gateway cutover retired the
# old Wonderware Host/Port/SharedSecret keys — the code now reads ServerHistorian__Endpoint and builds
# a Uri from it, so Enabled=true with an empty Endpoint crashes the Host at startup
# (HistorianGatewayClientAdapter.Create -> UriFormatException "The URI is empty").
# To enable: OTOPCUA_HISTORIAN_ENABLED=true + OTOPCUA_HISTORIAN_ENDPOINT=https://<host>:5222, and
# export ServerHistorian__ApiKey=histgw_<id>_<secret> at `docker compose up` (never commit the key).
ServerHistorian__Enabled: "${OTOPCUA_HISTORIAN_ENABLED:-false}"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-}"
ServerHistorian__ApiKey: "${ServerHistorian__ApiKey:-}"
# UseTls must match the Endpoint scheme (http:// = h2c ⇒ false). Parameterized because a
# locally-run gateway is usually plaintext h2c while a deployed one is https.
ServerHistorian__UseTls: "${OTOPCUA_HISTORIAN_USETLS:-true}"
ServerHistorian__AllowUntrustedServerCertificate: "true"
# Continuous historization of driver tag values (Gitea #491's live gate). Gated on BOTH
# ContinuousHistorization:Enabled AND ServerHistorian:Enabled — with either off no recorder is
# spawned, so this stays inert on the default rig. OutboxPath is the FasterLog crash boundary and
# points at the per-node named volume so the outbox survives a container recreate (that durability
# is exactly what the restart-convergence leg of the gate exercises).
# To run the gate:
# OTOPCUA_HISTORIAN_ENABLED=true OTOPCUA_HISTORIAN_ENDPOINT=http://<gw-host>:5221 \
# OTOPCUA_HISTORIAN_USETLS=false OTOPCUA_CONTHIST_ENABLED=true \
# docker compose -f docker-dev/docker-compose.yml up -d central-1 central-2
ContinuousHistorization__Enabled: "${OTOPCUA_CONTHIST_ENABLED:-false}"
ContinuousHistorization__OutboxPath: "/app/data/historization-outbox"
ContinuousHistorization__CommitMode: "${OTOPCUA_CONTHIST_COMMITMODE:-PerEntry}"
ContinuousHistorization__DrainIntervalSeconds: "${OTOPCUA_CONTHIST_DRAIN_SECONDS:-2}"
# Pin EF Core + ASP.NET Core to Warning so the per-poll Deployment SELECT /
# "Executed DbCommand" Information|Debug lines stop flooding the Serilog
# pipeline and starving the Akka cluster heartbeat thread. The host logs via
# Serilog (AddZbSerilog → ReadFrom.Configuration); these env vars override
# Serilog:MinimumLevel:Override:* (app/Akka levels are left untouched).
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# Consolidated pair-local config cache (ZB.MOM.WW.LocalDb). Every driver-role node
# (central is admin,driver) gets one, on a per-node named volume so the cache survives
# container recreates. The central pair leaves replication OFF (default) — only the
# site-a pair replicates, as the enablement demo; site-b is the default-OFF pin.
LocalDb__Path: "/app/data/otopcua-localdb.db"
ports:
- "4840:4840"
volumes:
- otopcua-localdb-central-1:/app/data
- otopcua-localdb-central-1:/data
central-2:
<<: *otopcua-host
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
<<: [*secrets-env, *ldap-env, *apikeys-env]
OTOPCUA_ROLES: "admin,driver,cluster-MAIN"
ASPNETCORE_URLS: "http://+:9000"
ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Sql poll driver — the named connection string a Sql driver instance references by
# `connectionStringRef: "DevSql"`. Resolved in-process by SqlConnectionStringResolver (driver
# node) and SqlDriverBrowser (AdminUI browse), env-only, never persisted to ConfigDb. Points at
# the rig's own `sql` container, a SEPARATE `DevSql` database seeded with dbo.TagValues (KeyValue)
# + dbo.LatestStatus (WideRow). Committed-dev-secret exception, same as ConfigDb above.
Sql__ConnectionStrings__DevSql: "Server=sql,1433;Database=DevSql;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;"
# Postgres + Oracle fixture connection strings — see central-1. Both centrals must carry them:
# :9200 round-robins, so the AdminUI browse can land on either node.
Sql__ConnectionStrings__FixturePg: "Host=10.100.0.35;Port=15432;Database=sqlpollfixture;Username=otopcua;Password=OtOpcUaPg2026x"
Sql__ConnectionStrings__FixtureOracle: "User Id=otopcua;Password=OtOpcUaOra2026x;Data Source=10.100.0.35:11521/FREEPDB1"
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "central-2"
# Per-cluster mesh Phase 3 — serves the artifact to FetchAndCache site nodes (see central-1).
# Always Direct; ConfigServe:ApiKey must match the site nodes' ConfigSource:ApiKey.
ConfigServe__GrpcListenPort: "4055"
ConfigServe__ApiKey: "configserve-docker-dev-key"
ConfigSource__Mode: "Direct"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream) — see central-1. central-2 carries
# a cluster-* role + admin, so both Telemetry:Mode=Grpc and TelemetryDial:Mode=Grpc are
# required (SplitTopologyTransportValidator); the dedicated :4056 listener binds regardless.
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
TelemetryDial__Mode: "Grpc"
TelemetryDial__ApiKey: "telemetry-docker-dev-key"
# Both redundancy peers are seeds (#459 / ScadaBridge parity) — see central-1. SELF FIRST:
# central-2 lists itself as seed-nodes[0], which is what lets it cold-start while central-1
# is down (it previously listed central-1 first and simply never came Up in that case). Per
# per-cluster mesh Phase 6, central-2's seed list contains ONLY the MAIN pair — no site node.
Cluster__SeedNodes__0: "akka.tcp://otopcua@central-2:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@central-1:4053"
# Per-cluster mesh Phase 6: cluster-MAIN marks this node as a member of the MAIN mesh — see
# central-1's Cluster__Roles comment.
Cluster__Roles__0: "admin"
Cluster__Roles__1: "driver"
Cluster__Roles__2: "cluster-MAIN"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see central-1.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport — see central-1.
# ClusterPsks is central-side only; both central nodes carry the same three-cluster map.
MeshTransport__GrpcPsk: "dev-mesh-psk-MAIN"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
MeshTransport__ClusterPsks__MAIN: "dev-mesh-psk-MAIN"
MeshTransport__ClusterPsks__SITE-A: "dev-mesh-psk-SITE-A"
MeshTransport__ClusterPsks__SITE-B: "dev-mesh-psk-SITE-B"
Security__Jwt__SigningKey: "docker-dev-signing-key-with-at-least-32-bytes-of-utf8-content-12345"
Security__Jwt__Issuer: "otopcua-dev"
Security__Jwt__Audience: "otopcua-dev"
Security__Ldap__Enabled: "true"
Security__Ldap__DevStubMode: "false"
Security__Ldap__Server: "10.100.0.35"
Security__Ldap__Port: "3893"
Security__Ldap__Transport: "None"
Security__Ldap__AllowInsecure: "true"
Security__Ldap__SearchBase: "dc=zb,dc=local"
Security__Ldap__ServiceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
Security__Ldap__ServiceAccountPassword: "serviceaccount123"
Security__DeployApiKey: "docker-dev-deploy-key"
Security__Auth__DisableLogin: "${OTOPCUA_DISABLE_LOGIN:-true}"
# Node ACL enforcement, the dark switch — same env override and same default-OFF as central-1.
# Both centrals must carry it: `up -d central-1 central-2` recreates both, and a pair split across
# two enforcement postures is not a rig anyone can reason about.
Security__Acl__Enabled: "${OTOPCUA_ACL_ENABLED:-false}"
# Data-plane group→role baseline (see central-1).
Security__Ldap__GroupToRole__ReadOnly: "ReadOnly"
Security__Ldap__GroupToRole__WriteOperate: "WriteOperate"
Security__Ldap__GroupToRole__WriteTune: "WriteTune"
Security__Ldap__GroupToRole__WriteConfigure: "WriteConfigure"
Security__Ldap__GroupToRole__AlarmAck: "AlarmAck"
# Server-side HistoryRead via the ZB.MOM.WW.HistorianGateway sidecar. DISABLED by default: the
# docker-dev rig has no historian gateway to point at, and the historian-gateway cutover retired the
# old Wonderware Host/Port/SharedSecret keys — the code now reads ServerHistorian__Endpoint and builds
# a Uri from it, so Enabled=true with an empty Endpoint crashes the Host at startup
# (HistorianGatewayClientAdapter.Create -> UriFormatException "The URI is empty").
# To enable: OTOPCUA_HISTORIAN_ENABLED=true + OTOPCUA_HISTORIAN_ENDPOINT=https://<host>:5222, and
# export ServerHistorian__ApiKey=histgw_<id>_<secret> at `docker compose up` (never commit the key).
ServerHistorian__Enabled: "${OTOPCUA_HISTORIAN_ENABLED:-false}"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-}"
ServerHistorian__ApiKey: "${ServerHistorian__ApiKey:-}"
ServerHistorian__UseTls: "${OTOPCUA_HISTORIAN_USETLS:-true}"
ServerHistorian__AllowUntrustedServerCertificate: "true"
# Continuous historization — see central-1 for the full note + the gate recipe. Both centrals
# carry it so the pair is symmetric; :9200 round-robins them, and the recorder must exist on
# whichever node holds the drivers.
ContinuousHistorization__Enabled: "${OTOPCUA_CONTHIST_ENABLED:-false}"
ContinuousHistorization__OutboxPath: "/app/data/historization-outbox"
ContinuousHistorization__CommitMode: "${OTOPCUA_CONTHIST_COMMITMODE:-PerEntry}"
ContinuousHistorization__DrainIntervalSeconds: "${OTOPCUA_CONTHIST_DRAIN_SECONDS:-2}"
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# Pair-local config cache; central pair leaves replication OFF (see central-1).
LocalDb__Path: "/app/data/otopcua-localdb.db"
ports:
- "4841:4840"
volumes:
- otopcua-localdb-central-2:/app/data
- otopcua-localdb-central-2:/data
# ── Site A cluster (2-node driver-only) ─────────────────────────────────────
# Per-cluster mesh Phase 6: site-a-1 and site-a-2 form their OWN 2-node "SITE-A" Akka mesh,
# separate from MAIN and SITE-B — self-first seeded WITHIN the pair, never off central. No UI,
# no user auth; central manages + deploys to this mesh over Grpc/FetchAndCache,
# not gossip (central and site-a share no seed list, so they never form one ring).
site-a-1:
<<: *otopcua-host
healthcheck: *akka-founder-healthcheck
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
OTOPCUA_ROLES: "driver,cluster-SITE-A"
# Per-cluster mesh Phase 4: driver-only site nodes hold NO ConfigDb connection string. Their
# config comes from central via ConfigSource:Mode=FetchAndCache (gRPC fetch → LocalDb cache), their
# scripted-alarm condition state lives in LocalDb, and central persists their deploy acks. Direct
# mode is invalid here — ConfigSourceOptionsValidator fails start for a driver-only node set Direct.
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "site-a-1"
# FetchAndCache is MANDATORY for driver-only nodes (validator-enforced): they fetch the deployed
# artifact from central over gRPC (endpoints below) and read ONLY the LocalDb cache — no central
# SQL config read (they have no connection string). Central stays Direct + keeps ConfigServe.
# ConfigSource:ApiKey must equal central's ConfigServe:ApiKey (the interceptor is fail-closed).
ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key"
# #520 stage 6 — node ACL enforcement, the dark switch. Default OFF everywhere, exactly as in
# production; the site-a pair is the demo/live-gate subject, so it reads an env override:
# OTOPCUA_ACL_ENABLED=true docker compose -f docker-dev/docker-compose.yml up -d site-a-1 site-a-2
# With it on, EVERY data-plane operation needs a matching NodeAcl grant — a principal with no
# grant connects and sees nothing. site-b stays hardcoded OFF as the A/B control.
Security__Acl__Enabled: "${OTOPCUA_ACL_ENABLED:-false}"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream) — see central-1/central-2. This
# node carries cluster-SITE-A + driver, so SplitTopologyTransportValidator requires
# Telemetry:Mode=Grpc (mandatory, not a dark switch, as of Phase 6). The dedicated :4056
# listener binds regardless (the node "always hosts" per docs/Telemetry.md). Port checked
# free against this node's other ports: Akka 4053, OPC UA 4840, HTTP 9000, LocalDb sync 9001
# (below). ApiKey must equal central's TelemetryDial:ApiKey (fail-closed interceptor).
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
# Per-cluster mesh Phase 6: site-a-1 seeds its OWN "SITE-A" mesh (itself first, site-a-2
# second) — it no longer seeds off central-1. Central is reached over Grpc/
# FetchAndCache, never gossip; central and site-a share no seed list.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-1:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-2:4053"
# Simultaneous-cold-start split-brain guard (SITE-A enablement demo). With the pair no longer
# startup-serialized, this is what stops both self-first seeds forming their own 1-node cluster:
# the lower-address node (site-a-1) founds immediately; the higher (site-a-2) probes site-a-1 and
# joins it when reachable, or forms alone only if site-a-1 is truly down. Akka gets NO config
# seeds when this is on (BuildClusterOptions); ClusterBootstrapCoordinator issues JoinSeedNodes.
Cluster__BootstrapGuard__Enabled: "true"
Cluster__Roles__0: "driver"
Cluster__Roles__1: "cluster-SITE-A"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
# central-1/central-2. Central dials this node's own NodeCommandService; there is no gossip
# path anymore.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport — see central-1.
# GrpcPsk is this node's own key for its cluster (SITE-A); CentralGrpcEndpoints is the gRPC
# where this node posts its deploy ack. No ClusterPsks here — that map is central-side
# only.
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
# Quiet EF/AspNetCore SQL flood — see central-1 (Serilog override). mem_limit/
# mem_reservation are inherited from the *otopcua-host anchor.
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
# Resolved at runtime by GalaxyDriver.ResolveApiKey when a DriverInstance's
# Gateway.ApiKeySecretRef = "env:GALAXY_MXGW_API_KEY".
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# Pair-local config cache + REPLICATION ON — the site-a pair is the enablement demo.
# site-a-1 is the initiator: it binds the h2c sync listener on 9001 AND dials the peer.
# Setting SyncListenPort makes Program.cs add a dedicated Http2-only Kestrel listener and
# re-bind the primary HTTP port (an explicit Listen* otherwise discards ASPNETCORE_URLS).
# Alarm store-and-forward buffer, Phase 2. Enabled on BOTH halves of the pair so the
# buffer is a live, replicated table rather than an empty one — the live gate needs a real
# queue to watch converge, and needs to see that only the Primary drains it.
#
# There is no HistorianGateway on this rig, so the endpoint below is deliberately
# unresolvable: every drain attempt fails, which is exactly the historian-outage state the
# buffer exists for. Note the endpoint must still be a syntactically valid absolute http(s)
# URI or the host refuses to start — ServerHistorianOptionsValidator is consumer-gated, so
# AlarmHistorian:Enabled=true makes it required even while ServerHistorian:Enabled=false.
#
# MaxAttempts is raised far above the production default of 10. With a permanently
# unreachable gateway the default would dead-letter the whole queue about five minutes into
# the outage, turning a buffering test into a dead-letter test.
AlarmHistorian__Enabled: "true"
AlarmHistorian__MaxAttempts: "1000000"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}"
# Required whenever the section is consumed: the gateway client validates its own options at
# construction and throws on an empty key, so a keyless node crash-loops during Akka startup
# rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint
# above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via
# ServerHistorian__ApiKey from the environment.
ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}"
# Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER
# direction, so an http endpoint with the default UseTls=true crash-loops the host.
ServerHistorian__UseTls: "false"
LocalDb__Path: "/app/data/otopcua-localdb.db"
LocalDb__SyncListenPort: "9001"
LocalDb__Replication__PeerAddress: "http://site-a-2:9001"
# DEV-ONLY committed key (rig philosophy — like the SQL password + secrets KEK above). It
# MUST be byte-identical on both nodes: the interceptor is fail-closed, so any mismatch
# silently stops the pair converging. NEVER reuse this key outside this local dev rig.
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
# Since LocalDb 0.2.0 the WIRE message is bounded by MaxBatchBytes (2 MB default, left
# unset), not by this. MaxBatchSize is now the secondary row cap AND the DB read-page size,
# and the read page is materialised in memory before the byte-budget split runs — so 16 is
# kept to bound that transient allocation (16 × ≈171 KB artifact chunk rows ≈ 2.7 MB).
LocalDb__Replication__MaxBatchSize: "16"
ports:
- "4842:4840"
volumes:
- otopcua-localdb-site-a-1:/app/data
- otopcua-localdb-site-a-1:/data
site-a-2:
<<: *otopcua-host
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
# SITE-A is the BOOTSTRAP-GUARD enablement demo: NO startup serialization on the pair — both
# nodes start simultaneously (the exact race that split-brained in the Phase 6 gate). The
# Cluster:BootstrapGuard (enabled in both site-a env blocks) is the ONLY thing preventing the
# split: it makes the lower-address node the founder and the higher-address node probe-then-join.
# site-b keeps the depends_on:service_healthy serialization + guard OFF, as the A/B control.
environment:
OTOPCUA_ROLES: "driver,cluster-SITE-A"
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "site-a-2"
# Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key"
# #520 stage 6 — node ACL enforcement, the dark switch. Default OFF everywhere, exactly as in
# production; the site-a pair is the demo/live-gate subject, so it reads an env override:
# OTOPCUA_ACL_ENABLED=true docker compose -f docker-dev/docker-compose.yml up -d site-a-1 site-a-2
# With it on, EVERY data-plane operation needs a matching NodeAcl grant — a principal with no
# grant connects and sees nothing. site-b stays hardcoded OFF as the A/B control.
Security__Acl__Enabled: "${OTOPCUA_ACL_ENABLED:-false}"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream) — see central-1/site-a-1.
# Telemetry:Mode=Grpc is mandatory as of Phase 6 (cluster-SITE-A + driver); the dedicated
# :4056 listener binds regardless.
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
# Per-cluster mesh Phase 6: site-a-2 seeds its OWN "SITE-A" mesh (itself first, site-a-1
# second) — it no longer seeds off central-1.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-a-2:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-a-1:4053"
# Bootstrap-guard enablement demo — see site-a-1. site-a-2 is the HIGHER address, so the guard
# makes it probe site-a-1 and join it (peer-first) rather than race-form its own cluster.
Cluster__BootstrapGuard__Enabled: "true"
# R3-04 (01/C-9) standing pin: 'Driver' (capital D) — a deliberately miscased plain role.
# EffectiveRoles normalizes it silently (Program.cs and AkkaClusterOptions.Roles both still
# gossip lower-case 'driver'), so post-fix this node behaves identically to site-a-1; pre-fix
# every one of the four role-scoped validators skipped its driver-scoped rules for this node,
# and site-a elected no Primary (RedundancyStateActor.SelectDriverPrimary matches ordinally).
# cluster-SITE-A keeps its own spelling verbatim by design — do not "fix" that half.
Cluster__Roles__0: "Driver"
Cluster__Roles__1: "cluster-SITE-A"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
# central-1/site-a-1.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport (Phase 0) — see central-1/site-a-1. SHIPPED DARK. Same SITE-A key as
# site-a-1 (must be byte-identical — both nodes authenticate against the same cluster's PSK).
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-A"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# site-a-2 is the passive half of the replicating pair: it binds the sync listener on 9001
# but does NOT dial (no PeerAddress). The replication stream is bidirectional, so a-1's
# single dial carries both directions. Same key + MaxBatchSize as a-1 (byte-identical key
# is mandatory — the interceptor fail-closes on a mismatch; MaxBatchSize is now a read-page
# bound rather than a byte proxy — see the a-1 comment).
# Alarm store-and-forward buffer, Phase 2. Enabled on BOTH halves of the pair so the
# buffer is a live, replicated table rather than an empty one — the live gate needs a real
# queue to watch converge, and needs to see that only the Primary drains it.
#
# There is no HistorianGateway on this rig, so the endpoint below is deliberately
# unresolvable: every drain attempt fails, which is exactly the historian-outage state the
# buffer exists for. Note the endpoint must still be a syntactically valid absolute http(s)
# URI or the host refuses to start — ServerHistorianOptionsValidator is consumer-gated, so
# AlarmHistorian:Enabled=true makes it required even while ServerHistorian:Enabled=false.
#
# MaxAttempts is raised far above the production default of 10. With a permanently
# unreachable gateway the default would dead-letter the whole queue about five minutes into
# the outage, turning a buffering test into a dead-letter test.
AlarmHistorian__Enabled: "true"
AlarmHistorian__MaxAttempts: "1000000"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}"
# Required whenever the section is consumed: the gateway client validates its own options at
# construction and throws on an empty key, so a keyless node crash-loops during Akka startup
# rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint
# above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via
# ServerHistorian__ApiKey from the environment.
ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}"
# Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER
# direction, so an http endpoint with the default UseTls=true crash-loops the host.
ServerHistorian__UseTls: "false"
LocalDb__Path: "/app/data/otopcua-localdb.db"
LocalDb__SyncListenPort: "9001"
LocalDb__Replication__ApiKey: "dev-site-a-localdb-sync-key"
LocalDb__Replication__MaxBatchSize: "16"
ports:
- "4843:4840"
volumes:
- otopcua-localdb-site-a-2:/app/data
- otopcua-localdb-site-a-2:/data
# ── Site B cluster (2-node driver-only) ─────────────────────────────────────
# Per-cluster mesh Phase 6: site-b-1 and site-b-2 form their OWN 2-node "SITE-B" Akka mesh,
# separate from MAIN and SITE-A — same shape as site-a-1/site-a-2, see that pair's header.
site-b-1:
<<: *otopcua-host
healthcheck: *akka-founder-healthcheck
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
environment:
OTOPCUA_ROLES: "driver,cluster-SITE-B"
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "site-b-1"
# Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream) — see central-1/site-a-1.
# Telemetry:Mode=Grpc is mandatory as of Phase 6 (cluster-SITE-B + driver); the dedicated
# :4056 listener binds regardless.
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
# Per-cluster mesh Phase 6: site-b-1 seeds its OWN "SITE-B" mesh (itself first, site-b-2
# second) — it no longer seeds off central-1.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-1:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-b-2:4053"
Cluster__Roles__0: "driver"
Cluster__Roles__1: "cluster-SITE-B"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
# central-1/site-a-1.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport (Phase 0) — see central-1. SHIPPED DARK. GrpcPsk is this node's own
# key for its cluster (SITE-B); CentralGrpcEndpoints is where it posts its deploy ack. No
# ClusterPsks here — that map is central-side only.
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# site-b is the default-OFF pin: it gets the pair-local cache but NO SyncListenPort and NO
# replication config, so no sync listener binds and ISyncStatus stays disconnected/Healthy.
# This is what the live gate's check 8 verifies — the cache works locally with replication off.
# Alarm store-and-forward with replication OFF — the other half of the site-b pin. The sink
# must work as a plain node-local buffer here, with no sync listener and no peer traffic.
AlarmHistorian__Enabled: "true"
AlarmHistorian__MaxAttempts: "1000000"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}"
# Required whenever the section is consumed: the gateway client validates its own options at
# construction and throws on an empty key, so a keyless node crash-loops during Akka startup
# rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint
# above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via
# ServerHistorian__ApiKey from the environment.
ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}"
# Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER
# direction, so an http endpoint with the default UseTls=true crash-loops the host.
ServerHistorian__UseTls: "false"
LocalDb__Path: "/app/data/otopcua-localdb.db"
ports:
- "4844:4840"
volumes:
- otopcua-localdb-site-b-1:/app/data
- otopcua-localdb-site-b-1:/data
site-b-2:
<<: *otopcua-host
depends_on:
sql: { condition: service_healthy }
central-1: { condition: service_started }
migrator: { condition: service_completed_successfully }
# Serialize the SITE-B pair's startup so site-b-1 forms the mesh first and site-b-2 JOINS it — see
# the identical comment on site-a-2. Without this both self-first seeds form their own single-node
# cluster on simultaneous start (split brain).
site-b-1: { condition: service_healthy }
environment:
# R3-04 (01/S-12) standing pin: this is the rig's ONLY Cluster:Roles-ONLY node — deliberately
# no OTOPCUA_ROLES at all (every other node sets both, which is why neither R3-04 finding could
# reproduce on the rig before this). Cluster__Roles__0/1 below are this node's sole role source.
# Per-cluster mesh Phase 4: no ConfigDb string on driver-only site nodes (see site-a-1).
Cluster__Hostname: "0.0.0.0"
Cluster__Port: "4053"
Cluster__PublicHostname: "site-b-2"
# Per-cluster mesh Phase 4: FetchAndCache mandatory for driver-only nodes (see site-a-1) — no
# ConfigDb string; fetch the artifact from central over gRPC and read only the LocalDb cache.
ConfigSource__Mode: "FetchAndCache"
ConfigSource__CentralFetchEndpoints__0: "http://central-1:4055"
ConfigSource__CentralFetchEndpoints__1: "http://central-2:4055"
ConfigSource__ApiKey: "configserve-docker-dev-key"
# Per-cluster mesh Phase 5/6 (gRPC live-telemetry stream) — see central-1/site-a-1.
# Telemetry:Mode=Grpc is mandatory as of Phase 6 (cluster-SITE-B + driver); the dedicated
# :4056 listener binds regardless.
Telemetry__GrpcListenPort: "4056"
Telemetry__Mode: "Grpc"
Telemetry__ApiKey: "telemetry-docker-dev-key"
# Per-cluster mesh Phase 6: site-b-2 seeds its OWN "SITE-B" mesh (itself first, site-b-1
# second) — it no longer seeds off central-1.
Cluster__SeedNodes__0: "akka.tcp://otopcua@site-b-2:4053"
Cluster__SeedNodes__1: "akka.tcp://otopcua@site-b-1:4053"
Cluster__Roles__0: "driver"
Cluster__Roles__1: "cluster-SITE-B"
# Mesh command transport (per-cluster mesh Phase 2, made mandatory by Phase 6) — see
# central-1/site-a-1.
MeshTransport__Mode: "${OTOPCUA_MESH_MODE:-Grpc}"
# gRPC command transport (Phase 0) — see central-1/site-b-1. SHIPPED DARK. Same SITE-B key as
# site-b-1 (must be byte-identical).
MeshTransport__GrpcPsk: "dev-mesh-psk-SITE-B"
MeshTransport__CentralGrpcEndpoints__0: "http://central-1:4055"
MeshTransport__CentralGrpcEndpoints__1: "http://central-2:4055"
<<: [*secrets-env, *ldap-env, *apikeys-env]
Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning"
Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning"
GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}"
# site-b default-OFF pin (see site-b-1): cache on, replication off.
# Alarm store-and-forward with replication OFF — the other half of the site-b pin. The sink
# must work as a plain node-local buffer here, with no sync listener and no peer traffic.
AlarmHistorian__Enabled: "true"
AlarmHistorian__MaxAttempts: "1000000"
ServerHistorian__Endpoint: "${OTOPCUA_HISTORIAN_ENDPOINT:-http://histgw-absent.invalid:5222}"
# Required whenever the section is consumed: the gateway client validates its own options at
# construction and throws on an empty key, so a keyless node crash-loops during Akka startup
# rather than degrading. DEV-ONLY placeholder — it authenticates nothing, because the endpoint
# above resolves nowhere. NEVER reuse this shape for a real gateway key; supply those via
# ServerHistorian__ApiKey from the environment.
ServerHistorian__ApiKey: "${OTOPCUA_HISTORIAN_APIKEY:-histgw_dev_rig-no-gateway-present}"
# Must agree with the endpoint scheme above — the client throws on a mismatch in EITHER
# direction, so an http endpoint with the default UseTls=true crash-loops the host.
ServerHistorian__UseTls: "false"
LocalDb__Path: "/app/data/otopcua-localdb.db"
ports:
- "4845:4840"
volumes:
- otopcua-localdb-site-b-2:/app/data
- otopcua-localdb-site-b-2:/data
traefik:
image: traefik:v3.1
command:
- --entrypoints.web.address=:80
- --providers.file.filename=/etc/traefik/dynamic.yml
- --providers.file.watch=true
- --api.insecure=true
# Access log, on deliberately: the 2026-08-17 live gate saw a transient 404 on the
# first POST /api/deployments (~40 s after boot) that can only mean "no router
# matched" — the AdminUI has no 404 path on that endpoint and a matched router with
# every server down answers 503. `defaultmode=keep` keeps RouterName/ServiceName in
# each line, which is empty on an unmatched request — that is how the next
# occurrence gets diagnosed. See docker-dev/README.md §"First-POST 404".
- --accesslog=true
- --accesslog.fields.defaultmode=keep
ports:
- "9200:80" # host port 9200 → traefik :80 entrypoint (80 conflicts with scadabridge-traefik)
- "8089:8080" # 8080 conflicts with the sister scadalink dev stack
volumes:
- ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
depends_on:
- central-1
- central-2
volumes:
# SQL Server data dir — persists the OtOpcUa ConfigDb across container recreates.
otopcua-mssql-data:
# Per-node pair-local config-cache (ZB.MOM.WW.LocalDb) files. One volume per node — the
# cache is node-local, not shared; it persists the cached deployment artifact (and, on the
# site-a pair, the replicated oplog state) across container recreates so a node can boot
# from cache after a restart even with central SQL down.
otopcua-localdb-central-1:
otopcua-localdb-central-2:
otopcua-localdb-site-a-1:
otopcua-localdb-site-a-2:
otopcua-localdb-site-b-1:
otopcua-localdb-site-b-2: