# docker-dev/ — Mac-friendly single-mesh hub-and-spoke fleet for v2 development + manual UI exercise. # # Topology: ONE Akka mesh seeded by `central-1`. Logical separation between # tenants is by ServerCluster.ClusterId rows (MAIN / SITE-A / SITE-B) in the one # shared `OtOpcUa` ConfigDb — NOT by separate meshes. All six host nodes join the # same gossip ring and the central UI deploys to every cluster over it. # # 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 — the ONLY UI + deploy # singleton, plus the MAIN cluster's OPC UA publishers. # Reachable at http://localhost:9200 (via Traefik). # central-1 is the Akka seed node; central-2 joins it. # site-a-1, site-a-2 OTOPCUA_ROLES=driver — driver-only members of the same # site-b-1, site-b-2 mesh, scoped to SITE-A / SITE-B by ClusterId. They # serve no UI and authenticate no users; the central # cluster manages and deploys to them over the mesh. # # 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. # Only the admin-role central nodes carry the Security__Ldap__* block. # 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. # # 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 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. # central-1 seeds the single Akka mesh that every other node joins. 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 depends_on: sql: { condition: service_healthy } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "admin,driver" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-1" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "admin" Cluster__Roles__1: "driver" 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" # 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}" ports: - "4840:4840" central-2: <<: *otopcua-host depends_on: sql: { condition: service_healthy } central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "admin,driver" ASPNETCORE_URLS: "http://+:9000" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "central-2" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "admin" Cluster__Roles__1: "driver" 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" # 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}" ports: - "4841:4840" # ── Site A cluster (2-node driver-only) ───────────────────────────────────── # Driver-only members of the single mesh, scoped to SITE-A by ClusterId. No UI, # no user auth — managed + deployed to by the central cluster over the mesh. # All site nodes seed central-1. site-a-1: <<: *otopcua-host depends_on: sql: { condition: service_healthy } central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "driver" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "site-a-1" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "driver" # 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}" ports: - "4842:4840" site-a-2: <<: *otopcua-host depends_on: sql: { condition: service_healthy } central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "driver" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "site-a-2" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "driver" Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" ports: - "4843:4840" # ── Site B cluster (2-node driver-only) ───────────────────────────────────── site-b-1: <<: *otopcua-host depends_on: sql: { condition: service_healthy } central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "driver" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "site-b-1" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "driver" Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" ports: - "4844:4840" site-b-2: <<: *otopcua-host depends_on: sql: { condition: service_healthy } central-1: { condition: service_started } migrator: { condition: service_completed_successfully } environment: OTOPCUA_ROLES: "driver" ConnectionStrings__ConfigDb: "Server=sql,1433;Database=OtOpcUa;User Id=sa;Password=OtOpcUa!Dev123;TrustServerCertificate=True;" Cluster__Hostname: "0.0.0.0" Cluster__Port: "4053" Cluster__PublicHostname: "site-b-2" Cluster__SeedNodes__0: "akka.tcp://otopcua@central-1:4053" Cluster__Roles__0: "driver" Serilog__MinimumLevel__Override__Microsoft.EntityFrameworkCore: "Warning" Serilog__MinimumLevel__Override__Microsoft.AspNetCore: "Warning" GALAXY_MXGW_API_KEY: "${GALAXY_MXGW_API_KEY:-mxgw_otopcua2_GI7-tNozYE6cXGUSgEzL3AHDV7bYcYIHdMwKYgyHdX4}" ports: - "4845:4840" traefik: image: traefik:v3.1 command: - --entrypoints.web.address=:80 - --providers.file.filename=/etc/traefik/dynamic.yml - --providers.file.watch=true - --api.insecure=true 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: