diff --git a/docker-dev/README.md b/docker-dev/README.md index e393131..4bd15fd 100644 --- a/docker-dev/README.md +++ b/docker-dev/README.md @@ -39,15 +39,25 @@ All containers bind Akka remoting to port `4053` inside their own network namesp ## Multi-tenancy -All eight host nodes write to the same `OtOpcUa` ConfigDb. The `ServerCluster` table differentiates the three Akka meshes: each Akka cluster maps to one row, and each `ClusterNode` row's `ClusterId` ties the runtime node back to its owning cluster scope. After the stack comes up clean for the first time, sign in to any admin UI and create the three rows (or do it via `dotnet run` against the Configuration project's seed script): +All eight host nodes write to the same `OtOpcUa` ConfigDb. The `ServerCluster` table differentiates the three Akka meshes: each Akka cluster maps to one row, and each `ClusterNode` row's `ClusterId` ties the runtime node back to its owning cluster scope. -| Akka mesh | Suggested `ClusterId` | Nodes (`ClusterNode.NodeId`) | +A one-shot `cluster-seed` Compose service (image `mcr.microsoft.com/mssql-tools`) waits for SQL + the EF auto-migration to complete and then INSERTs the rows below. The seed is **idempotent** — `IF NOT EXISTS` guards every insert — so re-runs on `docker compose up` are no-ops: + +| Akka mesh | `ServerCluster.ClusterId` | `ClusterNode.NodeId` rows | |---|---|---| -| Main | `MAIN` | `admin-a`, `admin-b`, `driver-a`, `driver-b` | +| Main | `MAIN` | `driver-a`, `driver-b` (OPC UA publishers) | | Site A | `SITE-A` | `site-a-1`, `site-a-2` | | Site B | `SITE-B` | `site-b-1`, `site-b-2` | -The `NodeId` for each `ClusterNode` row must match the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to find its own membership. +`ClusterNode` is the table for **OPC UA-publishing nodes** (not every Akka cluster member), which is why the main cluster's `admin-a` / `admin-b` don't get rows — they're control-plane-only. + +Each `ClusterNode.NodeId` matches the node's `Cluster__PublicHostname` env value (Compose service name) — that's the lookup the runtime uses to resolve its own membership. `ApplicationUri` follows the `urn:OtOpcUa:` convention. + +The SQL lives at `seed/seed-clusters.sql`; the wait-and-apply wrapper lives at `seed/entrypoint.sh`. To re-seed manually: + +```bash +docker compose -f docker-dev/docker-compose.yml run --rm cluster-seed +``` ## Bring up diff --git a/docker-dev/docker-compose.yml b/docker-dev/docker-compose.yml index 949117b..69ba80e 100644 --- a/docker-dev/docker-compose.yml +++ b/docker-dev/docker-compose.yml @@ -57,6 +57,20 @@ services: timeout: 5s retries: 20 + # ── Cluster seed (one-shot) ──────────────────────────────────────────────── + # Waits for SQL + the host containers' EF auto-migration, then INSERTs the + # three ServerCluster rows and the six ClusterNode rows that scope each Akka + # mesh inside the shared OtOpcUa ConfigDb. Idempotent — re-runs are no-ops. + cluster-seed: + image: mcr.microsoft.com/mssql-tools:latest + depends_on: + sql: + condition: service_healthy + volumes: + - ./seed:/seed:ro + entrypoint: ["/bin/bash", "/seed/entrypoint.sh"] + restart: "no" + ldap: image: bitnami/openldap:2.6 environment: diff --git a/docker-dev/seed/entrypoint.sh b/docker-dev/seed/entrypoint.sh new file mode 100755 index 0000000..d2799ce --- /dev/null +++ b/docker-dev/seed/entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# docker-dev cluster-seed entrypoint. Waits for the host containers to finish +# their EF Core auto-migration (which creates the ServerCluster table), then +# applies the idempotent seed script. +# +# Image: mcr.microsoft.com/mssql-tools (Debian + sqlcmd at /opt/mssql-tools18/bin). + +set -euo pipefail + +SQLCMD="/opt/mssql-tools18/bin/sqlcmd" +SERVER="${SQL_HOST:-sql},1433" +USER="${SQL_USER:-sa}" +PASS="${SQL_PASSWORD:-OtOpcUa!Dev123}" +DB="${SQL_DATABASE:-OtOpcUa}" + +run_sql() { + "$SQLCMD" -S "$SERVER" -U "$USER" -P "$PASS" -d "$DB" -No -b -h -1 "$@" +} + +echo "[cluster-seed] waiting for SQL Server to accept connections..." +until run_sql -Q "SELECT 1" >/dev/null 2>&1; do + sleep 2 +done +echo "[cluster-seed] SQL Server up." + +echo "[cluster-seed] waiting for $DB.ServerCluster (host containers must finish EF migration)..." +until run_sql -Q "IF OBJECT_ID('dbo.ServerCluster') IS NULL THROW 50001, 'missing', 1; SELECT 1" >/dev/null 2>&1; do + sleep 3 +done +echo "[cluster-seed] schema ready." + +echo "[cluster-seed] applying seed-clusters.sql..." +run_sql -i /seed/seed-clusters.sql + +echo "[cluster-seed] done." diff --git a/docker-dev/seed/seed-clusters.sql b/docker-dev/seed/seed-clusters.sql new file mode 100644 index 0000000..ff74996 --- /dev/null +++ b/docker-dev/seed/seed-clusters.sql @@ -0,0 +1,106 @@ +-- docker-dev cluster seed. Idempotent — safe to re-run on every `docker compose up`. +-- +-- Populates: +-- ServerCluster MAIN, SITE-A, SITE-B +-- ClusterNode driver-a, driver-b → MAIN +-- site-a-1, site-a-2 → SITE-A +-- site-b-1, site-b-2 → SITE-B +-- +-- ServerCluster.NodeCount + RedundancyMode are coupled by CHECK constraint: +-- NodeCount=1 ⇒ RedundancyMode='None' +-- NodeCount=2 ⇒ RedundancyMode∈('Warm','Hot') +-- +-- Each ClusterNode.ApplicationUri MUST be globally unique (UX_ClusterNode_ApplicationUri). +-- Convention: urn:OtOpcUa:. +-- +-- Host = Compose service name (resolves inside the otopcua-dev network). +-- OpcUaPort stays at the container-internal 4840; the host-side port mapping is in +-- docker-compose.yml ports: blocks and is irrelevant to ClusterNode rows. + +SET NOCOUNT ON; +SET XACT_ABORT ON; + +BEGIN TRANSACTION; + +------------------------------------------------------------------------------ +-- ServerCluster +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'MAIN') + INSERT INTO dbo.ServerCluster + (ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy) + VALUES + ('MAIN', 'Main cluster', 'zb', 'docker-dev', + 2, 'Warm', 1, + 'docker-dev seed — admin-a/admin-b control-plane, driver-a/driver-b OPC UA publishers.', + 'docker-dev-seed'); + +IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-A') + INSERT INTO dbo.ServerCluster + (ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy) + VALUES + ('SITE-A', 'Site A', 'zb', 'site-a', + 2, 'Warm', 1, + 'docker-dev seed — 2-node fused admin+driver cluster.', + 'docker-dev-seed'); + +IF NOT EXISTS (SELECT 1 FROM dbo.ServerCluster WHERE ClusterId = 'SITE-B') + INSERT INTO dbo.ServerCluster + (ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, Notes, CreatedBy) + VALUES + ('SITE-B', 'Site B', 'zb', 'site-b', + 2, 'Warm', 1, + 'docker-dev seed — 2-node fused admin+driver cluster.', + 'docker-dev-seed'); + +------------------------------------------------------------------------------ +-- ClusterNode — main cluster OPC UA publishers +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-a') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('driver-a', 'MAIN', 'driver-a', 4840, 8081, 'urn:OtOpcUa:driver-a', 200, 1, 'docker-dev-seed'); + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'driver-b') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('driver-b', 'MAIN', 'driver-b', 4840, 8081, 'urn:OtOpcUa:driver-b', 150, 1, 'docker-dev-seed'); + +------------------------------------------------------------------------------ +-- ClusterNode — site A +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-1') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-a-1', 'SITE-A', 'site-a-1', 4840, 8081, 'urn:OtOpcUa:site-a-1', 200, 1, 'docker-dev-seed'); + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-a-2') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-a-2', 'SITE-A', 'site-a-2', 4840, 8081, 'urn:OtOpcUa:site-a-2', 150, 1, 'docker-dev-seed'); + +------------------------------------------------------------------------------ +-- ClusterNode — site B +------------------------------------------------------------------------------ + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-1') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-b-1', 'SITE-B', 'site-b-1', 4840, 8081, 'urn:OtOpcUa:site-b-1', 200, 1, 'docker-dev-seed'); + +IF NOT EXISTS (SELECT 1 FROM dbo.ClusterNode WHERE NodeId = 'site-b-2') + INSERT INTO dbo.ClusterNode + (NodeId, ClusterId, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES ('site-b-2', 'SITE-B', 'site-b-2', 4840, 8081, 'urn:OtOpcUa:site-b-2', 150, 1, 'docker-dev-seed'); + +COMMIT TRANSACTION; + +------------------------------------------------------------------------------ +-- Summary (logged by sqlcmd output) +------------------------------------------------------------------------------ + +SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId; +SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase + FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;