16 Commits

Author SHA1 Message Date
Joseph Doherty adf1bd2693 build: drop orphaned AspNetCore.HealthChecks.UI.Client ref (UIResponseWriter removed) 2026-06-01 13:56:12 -04:00
Joseph Doherty bbff1d19b5 feat: adopt shared ZB.MOM.WW.Health probes; add /healthz; canonical writer 2026-06-01 13:46:49 -04:00
Joseph Doherty 2a7ff03718 feat: bridge ActorSystem into DI (transient) for shared health checks 2026-06-01 13:37:21 -04:00
Joseph Doherty 38e48299a4 build: reference ZB.MOM.WW.Health packages from the Gitea feed 2026-06-01 13:30:33 -04:00
Joseph Doherty 43228185b4 docs: convert standard diagrams from draw.io PNGs to inline Mermaid
Gitea renders mermaid inline, so the flow/state/hierarchy/DAG diagrams
move to text-in-markdown: auto-layout (removes the manual overlap-prone
draw.io step), diffable source, no committed binaries, and a dark-text
theme so labels stay legible. Keep draw.io PNGs only for the two complex
bespoke diagrams (logical architecture, env2 topology) where pixel
control still wins. All 24 mermaid blocks validated by rendering.
2026-06-01 00:23:00 -04:00
Joseph Doherty e3ca5ac0cf docs(spike): darken Mermaid label text for readability
Add explicit dark text color (per-class color + base theme override) to
the store-and-forward mermaid diagram so node/edge labels read clearly
regardless of gitea's page theme.
2026-06-01 00:08:08 -04:00
Joseph Doherty 4c5e7eb917 docs(spike): inline Mermaid for store-and-forward lifecycle
Swap the store-and-forward Message Lifecycle PNG embed for an inline
mermaid block to verify whether gitea renders mermaid in markdown. If it
does, the standard flow/state/hierarchy diagrams can move to inline
mermaid (text-only, auto-layout) instead of draw.io source + PNG.
2026-05-31 23:53:04 -04:00
Joseph Doherty bdee12f4e9 docs: render architecture & flow diagrams as draw.io charts
Replace ASCII-art diagrams across the README and docs/ with editable
.drawio sources plus exported PNGs, so the diagrams render clearly in
rendered markdown and can be maintained/regenerated instead of being
hand-edited as fragile text art. Non-diagram blocks (code, folder
trees, UI wireframes) were left as text.
2026-05-31 23:32:53 -04:00
Joseph Doherty 3763f6d2d8 docs: reframe README as the ScadaBridge implementation project
Retitle from 'SCADA System — Design Documentation' to ScadaBridge; the
overview now describes the repo as the full implementation (src/tests/docker
+ design docs as spec) rather than design docs only. Add Repository Layout
and Build/Test/Run sections. Component table + architecture diagrams unchanged.
2026-05-31 22:12:16 -04:00
Joseph Doherty 300841b205 chore: mark rename plan complete (all 7 tasks done) 2026-05-31 22:05:13 -04:00
Joseph Doherty 3797af7f0f chore: mark rename plan tasks 0-4 complete 2026-05-31 21:59:57 -04:00
Joseph Doherty a47317d010 docs: record git-ignored deploy/ scrub gap + resolution (incl. LDAP directory rename) 2026-05-31 21:58:49 -04:00
Joseph Doherty c899cb162c refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)
Renames the 13 SCADALINK_* runtime env vars → SCADABRIDGE_*, the ScadaLink__
.NET config keys → ScadaBridge__, the stale ScadaLink.Host.exe assembly name
→ ZB.MOM.WW.ScadaBridge.Host.exe, the scadalink_app SQL login → scadabridge_app,
and residual identifiers/comments/docs. Migration records (prior rename
tooling/design, DB-rename helper, this scrub script) carved out.

Adds tools/scrub-scadalink-refs.sh.
2026-05-31 21:50:38 -04:00
Joseph Doherty d69031dd08 docs: reorganize CLAUDE.md project-structure section 2026-05-31 21:41:29 -04:00
Joseph Doherty d317c07ea5 docs: add folder/repo rename implementation plan + task file 2026-05-31 21:39:23 -04:00
Joseph Doherty e01f3bdabe docs: add folder/repo rename design doc (scadalink-design → ScadaBridge) 2026-05-31 21:32:07 -04:00
69 changed files with 2055 additions and 890 deletions
+15 -1
View File
@@ -6,17 +6,31 @@ When a change is requested, the default assumption is: update the design doc *an
## Project Structure
### Top-level directories
- `src/` — C#/.NET implementation, one project per component (e.g. `ZB.MOM.WW.ScadaBridge.AuditLog`, `ZB.MOM.WW.ScadaBridge.NotificationOutbox`, `ZB.MOM.WW.ScadaBridge.SiteCallAudit`, `ZB.MOM.WW.ScadaBridge.CentralUI`, `ZB.MOM.WW.ScadaBridge.Host`, …). Solution file: `ZB.MOM.WW.ScadaBridge.slnx`.
- `tests/` — Test projects (unit + integration).
- `docs/` — Design documentation: `docs/requirements/` (high-level + per-component specs), `docs/test_infra/` (test infrastructure), `docs/plans/` (design-decision and implementation-plan docs). The spec the code implements.
- `docker/` — 8-node cluster topology (2 central + 3 sites), `deploy.sh`, per-node `appsettings.*.json`. See [`docker/README.md`](docker/README.md) for setup, ports, and management commands. Rebuild + redeploy with `bash docker/deploy.sh`.
- `docker-env2/` — Minimal second cluster topology (2 central + 1 site × 2 nodes), runs concurrently with `docker/` on host ports 91XX. Built specifically for testing the Transport (#24) feature with two real environments. See [`docker-env2/README.md`](docker-env2/README.md). Rebuild + redeploy with `bash docker-env2/deploy.sh`.
- `infra/` — Docker Compose for local test services (LDAP, MS SQL, OPC UA, SMTP, REST API, Traefik).
- `deploy/` — Production/on-host deployment artifacts (e.g. `deploy/wonder-app-vd03/`: `appsettings.Central.json`, `appsettings.Site.json`, `install.ps1`/`uninstall.ps1`, `RUNBOOK.md`).
- `deployments/` — Deployment topology notes (`docker-cluster.md`, `docker-cluster-env2.md`, `README.md`).
- `code-reviews/` — Per-component code-review notes (one folder per component, plus `_template`).
- `tools/` — Repo maintenance/utility scripts (e.g. `rename-to-scadabridge.sh`).
- `AkkaDotNet/` — Akka.NET reference documentation and best-practices notes.
- `deprecated/` — Retired docs/notes kept for reference.
- `logs/` — Local runtime log output.
- `vendor/` — Vendored third-party assets (currently an empty placeholder).
- `.claude/` — Claude Code project config (settings, skills, agents).
### Key documents
- `README.md` — Master index with component table and architecture diagrams.
- `docs/requirements/HighLevelReqs.md` — Complete high-level requirements covering all functional areas.
- `docs/requirements/Component-*.md` — Individual component design documents (one per component) — the spec the code implements.
- `docs/test_infra/test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL, SMTP, REST API, Traefik).
- `docs/plans/` — Design decision and implementation-plan documents from refinement sessions.
- `AkkaDotNet/` — Akka.NET reference documentation and best practices notes.
## Sister Projects
+3 -1
View File
@@ -15,7 +15,6 @@
<PackageVersion Include="Akka.Streams" Version="1.5.62" />
<PackageVersion Include="Akka.Streams.TestKit" Version="1.5.62" />
<PackageVersion Include="Akka.TestKit.Xunit2" Version="1.5.62" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="bunit" Version="2.0.33-preview" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FluentAssertions" Version="8.3.0" />
@@ -73,6 +72,9 @@
to mark tests as Skipped (not silently Passed) when MSSQL is unreachable.
-->
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
</ItemGroup>
+80 -96
View File
@@ -1,8 +1,10 @@
# SCADA System — Design Documentation
# ScadaBridge
ScadaBridge is a centrally-managed, distributed SCADA configuration and deployment platform built on Akka.NET, running across a central cluster and multiple site clusters in a hub-and-spoke topology.
## Overview
This document serves as the master index for the SCADA system design. The system is a centrally-managed, distributed SCADA configuration and deployment platform built on Akka.NET, running across a central cluster and multiple site clusters in a hub-and-spoke topology.
This repository is the full **implementation** project for ScadaBridge — the C#/.NET source (`src/`), tests (`tests/`), deployable Docker topology (`docker/`, `docker-env2/`, `infra/`), and the design documentation (`docs/`) that the code implements. This README is the master index: it links the per-component **design specs** (the spec the code in `src/` implements) and shows the system architecture. The solution file is `ZB.MOM.WW.ScadaBridge.slnx`.
### Technology Stack
@@ -24,6 +26,38 @@ This document serves as the master index for the SCADA system design. The system
- Central cluster: 2-node active/standby behind a load balancer.
- Site clusters: 2-node active/standby, headless (no UI).
## Repository Layout
| Path | Contents |
|------|----------|
| `src/` | C#/.NET implementation — one project per component (`ZB.MOM.WW.ScadaBridge.<Component>`). Solution: `ZB.MOM.WW.ScadaBridge.slnx`. |
| `tests/` | Unit and integration test projects. |
| `docs/` | Design documentation — `docs/requirements/` (high-level + per-component specs, the spec the code implements), `docs/test_infra/`, `docs/plans/`. |
| `docker/` | Primary 8-node cluster topology (2 central + 3 sites × 2 nodes + Traefik) + `deploy.sh`. |
| `docker-env2/` | Minimal second cluster (2 central + 1 site) for exercising Transport (#24) against a real second environment. |
| `infra/` | Local test services (MS SQL, LDAP, OPC UA, SMTP, REST API, Traefik). |
| `deploy/` | Production/on-host deployment artifacts (e.g. `wonder-app-vd03/`). |
| `AkkaDotNet/` | Akka.NET reference notes. |
## Build, Test & Run
```bash
# Build the solution
dotnet build ZB.MOM.WW.ScadaBridge.slnx
# Run the tests
dotnet test ZB.MOM.WW.ScadaBridge.slnx
# Bring up the primary local cluster (builds the scadabridge:latest image + recreates containers)
bash docker/deploy.sh # central load balancer at http://localhost:9000
# Drive the system from the CLI (reads ~/.scadabridge/config.json; test user has all roles)
dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- \
--username multi-role --password password template list
```
See [`docker/README.md`](docker/README.md) for ports and management commands, and [`src/ZB.MOM.WW.ScadaBridge.CLI/README.md`](src/ZB.MOM.WW.ScadaBridge.CLI/README.md) for the full CLI reference.
## Local Test Environments
Two Docker-based cluster topologies are available for local development and testing:
@@ -76,102 +110,52 @@ Both stacks share the infrastructure services in [`infra/`](infra/) (MS SQL, LDA
### Architecture Diagram (Logical)
```
Users (Blazor Server)
Load Balancer
┌────────────────────────┼────────────────────────────┐
│ CENTRAL CLUSTER │
│ (2-node active/standby) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Template │ │Deployment│ │ Central │ │
│ │ Engine │ │ Manager │ │ UI │ Blazor Svr │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Security │ │ Config │ │ Health │ │
│ │ & Auth │ │ DB │ │ Monitor │ │
│ │ (JWT/LDAP)│ │ (EF+IAud)│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ │
│ │ Inbound │ ◄── External Systems (X-API-Key) │
│ │ API │ POST /api/{method}, JSON │
│ └──────────┘ │
│ ┌──────────┐ │
│ │ Mgmt │ ◄── CLI (ClusterClient) │
│ │ Service │ ManagementActor + Receptionist │
│ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Ntf │ │ Site │ │ Audit │ Observ. / │
│ │ Outbox │ │ Call │ │ Log │ Audit area │
│ │ (#21) │ │ Audit │ │ (#23) │ │
│ │ │ │ (#22) │ │ │ │
│ └────▲─────┘ └────▲─────┘ └────▲─────┘ │
│ │ ingests │ ingests │ ingests │
│ │ (S&F) │ (telemetry)│ (telemetry + │
│ │ │ │ direct-write │
│ │ │ │ from Ntf Outbox │
│ │ │ │ & Inbound API) │
│ ┌───────────────────────────────────┐ │
│ │ Akka.NET Communication Layer │ │
│ │ ClusterClient: command/control │ │
│ │ gRPC Client: real-time streams │ │
│ │ (correlation IDs, per-pattern │ │
│ │ timeouts, message ordering) │ │
│ └──────────────┬────────────────────┘ │
│ ┌──────────────┴────────────────────┐ │
│ │ Configuration Database (EF) │──► MS SQL │
│ └───────────────────────────────────┘ (Config DB)│
│ │ Machine Data DB│
└─────────────────┼───────────────────────────────────┘
│ Akka.NET Remoting (command/control)
│ gRPC HTTP/2 (real-time data, port 8083)
┌────────────┼────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ SITE A │ │ SITE B │ │ SITE N │
│ (2-node)│ │ (2-node)│ │ (2-node)│
│ ┌─────┐ │ │ ┌─────┐ │ │ ┌─────┐ │
│ │Data │ │ │ │Data │ │ │ │Data │ │
│ │Conn │ │ │ │Conn │ │ │ │Conn │ │
│ │Layer │ │ │ │Layer │ │ │ │Layer │ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │Site │ │ │ │Site │ │ │ │Site │ │
│ │Runtm│ │ │ │Runtm│ │ │ │Runtm│ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │gRPC │ │ │ │gRPC │ │ │ │gRPC │ │
│ │Srvr │ │ │ │Srvr │ │ │ │Srvr │ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │S&F │ │ │ │S&F │ │ │ │S&F │ │
│ │Engine│ │ │ │Engine│ │ │ │Engine│ │
│ ├─────┤ │ │ ├─────┤ │ │ ├─────┤ │
│ │ExtSys│ │ │ │ExtSys│ │ │ │ExtSys│ │
│ │Gatwy │ │ │ │Gatwy │ │ │ │Gatwy │ │
│ └─────┘ │ │ └─────┘ │ │ └─────┘ │
│ SQLite │ │ SQLite │ │ SQLite │
└─────────┘ └─────────┘ └─────────┘
│ │ │
OPC UA / OPC UA / OPC UA /
Custom Custom Custom
Protocol Protocol Protocol
```
![Logical architecture](diagrams/architecture-logical.png)
<!-- source: diagrams/architecture-logical.drawio — edit, then re-export with export-drawio.sh -->
### Site Runtime Actor Hierarchy
```
Deployment Manager Singleton (Cluster Singleton)
├── Instance Actor (one per deployed, enabled instance)
├── Script Actor (coordinator, one per instance script)
│ │ └── Script Execution Actor (short-lived, per invocation)
│ ├── Alarm Actor (coordinator, one per alarm definition)
│ │ └── Alarm Execution Actor (short-lived, per on-trigger invocation)
│ └── ... (more Script/Alarm Actors)
├── Instance Actor
│ └── ...
└── ... (more Instance Actors)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
DMS["Deployment Manager Singleton<br/>(Cluster Singleton)"]
IA["Instance Actor<br/>(one per deployed, enabled instance)"]
IA2["Instance Actor<br/>( … )"]
MOREIA["… more Instance Actors"]
DMS --> IA
DMS --> IA2
DMS -.-> MOREIA
Site-Wide Akka Stream (attribute + alarm state changes)
├── All Instance Actors publish to the stream
└── Debug view subscribes with instance-level filtering
SA["Script Actor<br/>(coordinator, one per instance script)"]
AA["Alarm Actor<br/>(coordinator, one per alarm definition)"]
MORE1["… more Script /<br/>Alarm Actors"]
IA --> SA
IA --> AA
IA -.-> MORE1
SEA["Script Execution Actor<br/>(short-lived, per invocation)"]
AEA["Alarm Execution Actor<br/>(short-lived, per on-trigger invocation)"]
IA2C["… (Script / Alarm Actors)"]
SA --> SEA
AA --> AEA
IA2 -.-> IA2C
subgraph STREAM["Site-Wide Akka Stream"]
PUB["All Instance Actors"]
STR["Site-Wide Akka Stream<br/>(attribute + alarm state changes)"]
DBG["Debug view<br/>(instance-level filtering)"]
PUB -->|publish| STR
STR -->|subscribe filtered| DBG
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class DMS,STR alt
class IA,IA2,PUB proc
class SA,AA,DBG start
class SEA,AEA warn
class MOREIA,MORE1,IA2C muted
```
+9 -9
View File
@@ -22,7 +22,7 @@ The dominant theme is **graceful-degradation gaps**: several user-supplied input
URLs, malformed `--bindings`/`--overrides` JSON, non-JSON success bodies) are deserialized
or constructed without `try/catch`, so a normal user mistake surfaces as an unhandled
exception with a stack trace instead of a clean error message and exit code 1. A second
theme is **dead configuration**: the `SCADALINK_FORMAT` environment variable and the
theme is **dead configuration**: the `SCADABRIDGE_FORMAT` environment variable and the
`defaultFormat` config-file field are loaded by `CliConfig` but never consulted by any
command, so the documented format-precedence chain does not work. The third theme is
**substantial design-document drift**: `Component-CLI.md` describes a name-keyed,
@@ -126,7 +126,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
## Findings
### CLI-001 — `SCADALINK_FORMAT` env var and config-file format are dead; format precedence broken
### CLI-001 — `SCADABRIDGE_FORMAT` env var and config-file format are dead; format precedence broken
| | |
|--|--|
@@ -137,7 +137,7 @@ _Re-review (2026-05-28, `1eb6e97`):_
**Description**
`CliConfig.Load()` reads `SCADALINK_FORMAT` and the `defaultFormat` config-file field into
`CliConfig.Load()` reads `SCADABRIDGE_FORMAT` and the `defaultFormat` config-file field into
`CliConfig.DefaultFormat`, and `Component-CLI.md` documents a format-precedence chain
(command-line option → env var → config file). However, every command resolves the format
with `var format = result.GetValue(formatOption) ?? "json";` and `formatOption` is created
@@ -145,7 +145,7 @@ in `Program.cs:11` with `DefaultValueFactory = _ => "json"`. `GetValue` therefor
returns a non-null value ("json" when the flag is absent), so the `?? "json"` fallback never
fires and `config.DefaultFormat` is never consulted. The env var and config-file format
settings are dead code: `scadabridge site list` always outputs JSON regardless of
`SCADALINK_FORMAT=table` or a `defaultFormat` entry in `~/.scadabridge/config.json`. The
`SCADABRIDGE_FORMAT=table` or a `defaultFormat` entry in `~/.scadabridge/config.json`. The
documented behaviour silently does not work.
**Recommendation**
@@ -312,14 +312,14 @@ wrong element types, and JSON null).
Credentials are supplied only via `--username` / `--password`. A password on the command
line is visible to any local user via the process list (`ps`, `/proc/<pid>/cmdline`) and is
typically persisted into shell history. Unlike the management URL — which can also come
from `SCADALINK_MANAGEMENT_URL` or the config file — there is no environment-variable
from `SCADABRIDGE_MANAGEMENT_URL` or the config file — there is no environment-variable
fallback, no `--password-stdin`, and no interactive prompt for the password. For a tool
explicitly intended for CI/CD automation this materially increases the chance of credential
leakage.
**Recommendation**
Add a `SCADALINK_PASSWORD` environment variable fallback and/or a `--password-stdin`
Add a `SCADABRIDGE_PASSWORD` environment variable fallback and/or a `--password-stdin`
option (read the password from stdin), and document that `--password` on the command line
is discouraged. Optionally prompt interactively when stdin is a TTY and no password was
supplied.
@@ -327,7 +327,7 @@ supplied.
**Resolution**
Resolved 2026-05-16 (commit pending). Root cause confirmed — credentials had no
non-command-line source. Added `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` environment
non-command-line source. Added `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD` environment
fallbacks: `CliConfig.Load` now reads them into new `CliConfig.Username` / `Password`
properties (credentials are sourced from environment variables only, never the config
file, so they are not persisted). `CommandHelpers.ResolveCredential` resolves precedence
@@ -387,7 +387,7 @@ bind-connections`/`assign-area`, `data-connection assign/unassign`, `security ap
enable/disable`) are removed; previously-omitted commands (`instance alarm-override
set/delete/list`, `external-system method` subgroup, `site deploy-artifacts`) are added.
A note now points to `src/ZB.MOM.WW.ScadaBridge.CLI/README.md` as the authoritative reference. The
Configuration section also documents the new `SCADALINK_USERNAME`/`SCADALINK_PASSWORD`
Configuration section also documents the new `SCADABRIDGE_USERNAME`/`SCADABRIDGE_PASSWORD`
env vars (see CLI-006).
### CLI-008 — `--format` value is not validated
@@ -817,7 +817,7 @@ and a caller-supplied success handler. In duplicating it, two contracts that
`2` = authorization failure," so this is a contract regression.
2. **Error-message phrasing drift.** The two duplicated error paths
(`bundle:258-260`, `:264-266`) emit shorter messages that omit the
`SCADALINK_MANAGEMENT_URL` / `SCADALINK_USERNAME` env-var hints the canonical paths
`SCADABRIDGE_MANAGEMENT_URL` / `SCADABRIDGE_USERNAME` env-var hints the canonical paths
give — confusing if the user is trying to debug what's missing.
**Recommendation**
@@ -230,7 +230,7 @@ Resolved 2026-05-16 (commit pending). Root cause confirmed against source: the f
fell back to a literal `User Id=sa;Password=YourPassword;...` connection string when no
configured value was found. Removed the hardcoded fallback entirely. The factory now
resolves the connection string from the Host's appsettings files or, when those are not
present, from the `SCADALINK_DESIGNTIME_CONNECTIONSTRING` environment variable, and
present, from the `SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING` environment variable, and
throws a clear `InvalidOperationException` (naming both the config key and the env var)
when neither yields a value. Also hardened `SetBasePath` to be applied only when the
`ZB.MOM.WW.ScadaBridge.Host` directory exists, so the factory degrades cleanly instead of throwing
+214
View File
@@ -0,0 +1,214 @@
<mxfile host="app.diagrams.net">
<diagram id="arch-logical" name="Logical Architecture">
<mxGraphModel dx="1200" dy="900" grid="1" gridSize="10" guides="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1000" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- top: users + load balancer -->
<mxCell id="users" value="Users (Blazor Server)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="430" y="20" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="lb" value="Load Balancer / Traefik" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="430" y="92" width="180" height="40" as="geometry" />
</mxCell>
<mxCell id="e_users_lb" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="users" target="lb">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_lb_central" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="lb" target="central">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- central cluster container -->
<mxCell id="central" value="CENTRAL CLUSTER — 2-node active / standby" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fontSize=14;fillColor=#eef3fb;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="40" y="160" width="740" height="490" as="geometry" />
</mxCell>
<mxCell id="te" value="Template Engine" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="dm" value="Deployment Manager" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="300" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="ui" value="Central UI (Blazor Server)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="530" y="206" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="sec" value="Security &amp; Auth (JWT / LDAP)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="cfg" value="Configuration DB (EF + IAudit)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="300" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="health" value="Health Monitor" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="530" y="270" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="inapi" value="Inbound API" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="338" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="extsys" value="External Systems&#10;(X-API-Key)" style="shape=cloud;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="548" y="352" width="184" height="48" as="geometry" />
</mxCell>
<mxCell id="e_ext_in" value="POST /api/{method} · JSON" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;fontSize=10;" edge="1" parent="1" source="extsys" target="inapi">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="mgmt" value="Management Service" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="70" y="402" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="cli" value="CLI&#10;(ClusterClient)" style="shape=cloud;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1">
<mxGeometry x="548" y="414" width="184" height="48" as="geometry" />
</mxCell>
<mxCell id="e_cli_mgmt" value="ManagementActor + Receptionist" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;fontSize=10;" edge="1" parent="1" source="cli" target="mgmt">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="obslabel" value="Observability / Audit" style="text;html=1;align=left;verticalAlign=middle;fontStyle=2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="70" y="456" width="300" height="18" as="geometry" />
</mxCell>
<mxCell id="ntf" value="Notification Outbox (#21)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="70" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="sca" value="Site Call Audit (#22)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="300" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="audit" value="Audit Log (#23)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="530" y="478" width="200" height="44" as="geometry" />
</mxCell>
<mxCell id="comm" value="Akka.NET Communication Layer&#10;ClusterClient (command/control) · gRPC Client (real-time streams)" style="whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1">
<mxGeometry x="70" y="558" width="430" height="64" as="geometry" />
</mxCell>
<mxCell id="mssql" value="MS SQL&#10;Config DB · Machine Data DB" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="580" y="556" width="200" height="70" as="geometry" />
</mxCell>
<mxCell id="e_cfg_sql" value="EF Core" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=10;exitX=1;exitY=0.7;entryX=0;entryY=0.4;" edge="1" parent="1" source="cfg" target="mssql">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="515" y="301" />
<mxPoint x="515" y="560" />
</Array>
</mxGeometry>
</mxCell>
<!-- ingests edges -->
<mxCell id="e_ing_ntf" value="ingests (S&amp;F)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;" edge="1" parent="1" source="comm" target="ntf">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_ing_sca" value="ingests (telemetry)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;" edge="1" parent="1" source="comm" target="sca">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_ing_audit" value="ingests (telemetry + direct-write)" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;dashed=1;fontSize=9;exitX=0.95;exitY=0;entryX=0.1;entryY=1;" edge="1" parent="1" source="comm" target="audit">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- transport annotation between central and sites -->
<mxCell id="transport" value="Akka.NET Remoting (command/control) · gRPC HTTP/2 (real-time data, port 8083)" style="text;html=1;align=center;verticalAlign=middle;fontStyle=2;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="120" y="662" width="580" height="28" as="geometry" />
</mxCell>
<!-- SITE A -->
<mxCell id="siteA" value="SITE A — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="40" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="a_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="60" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="a_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="85" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- SITE B -->
<mxCell id="siteB" value="SITE B — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="295" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="b_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="315" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="b_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="340" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- SITE N -->
<mxCell id="siteN" value="SITE N — 2-node" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fontStyle=1;fillColor=#eafaf0;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="550" y="720" width="230" height="364" as="geometry" />
</mxCell>
<mxCell id="n_dcl" value="Data Connection Layer" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="758" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_rt" value="Site Runtime" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="806" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_grpc" value="gRPC Server" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="854" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_snf" value="Store-and-Forward Engine" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="902" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_esg" value="External System Gateway" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1">
<mxGeometry x="570" y="950" width="190" height="40" as="geometry" />
</mxCell>
<mxCell id="n_sql" value="SQLite" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="595" y="1002" width="140" height="64" as="geometry" />
</mxCell>
<!-- central -> sites transport edges -->
<mxCell id="e_c_a" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteA">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_c_b" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteB">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_c_n" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;strokeWidth=1.5;" edge="1" parent="1" source="comm" target="siteN">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- field protocol per site -->
<mxCell id="a_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="60" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="b_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="315" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="n_proto" value="OPC UA / Custom Protocol" style="whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;" vertex="1" parent="1">
<mxGeometry x="570" y="1100" width="190" height="36" as="geometry" />
</mxCell>
<mxCell id="e_a_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="a_dcl" target="a_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_b_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="b_dcl" target="b_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="e_n_proto" style="edgeStyle=orthogonalEdgeStyle;html=1;endArrow=block;" edge="1" parent="1" source="n_dcl" target="n_proto">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

+4 -4
View File
@@ -3,7 +3,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -20,7 +20,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -37,7 +37,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9121:8082" # Akka remoting
- "9123:8083" # gRPC streaming
@@ -53,7 +53,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9122:8082" # Akka remoting
- "9124:8083" # gRPC streaming
+8 -8
View File
@@ -3,7 +3,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -20,7 +20,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -37,7 +37,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-a-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9021:8082" # Akka remoting (host access for debugging)
- "9023:8083" # gRPC streaming
@@ -53,7 +53,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-a-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9022:8082" # Akka remoting
- "9024:8083" # gRPC streaming
@@ -69,7 +69,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-b-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9031:8082" # Akka remoting
- "9033:8083" # gRPC streaming
@@ -85,7 +85,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-b-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9032:8082" # Akka remoting
- "9034:8083" # gRPC streaming
@@ -101,7 +101,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-c-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9041:8082" # Akka remoting
- "9043:8083" # gRPC streaming
@@ -117,7 +117,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-site-c-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9042:8082" # Akka remoting
- "9044:8083" # gRPC streaming
+44 -17
View File
@@ -6,23 +6,50 @@ ScadaBridge uses a hub-and-spoke architecture:
- **Central Cluster**: Two-node active/standby Akka.NET cluster for management, UI, and coordination.
- **Site Clusters**: Two-node active/standby Akka.NET clusters at each remote site for data collection and local processing.
```
┌──────────────────────────┐
│ Central Cluster │
│ ┌──────┐ ┌──────┐ │
Users ──────────► │ │Node A│◄──►│Node B│ │
(HTTPS/LB) │ │Active│ │Stby │ │
│ └──┬───┘ └──┬───┘ │
└─────┼───────────┼────────┘
│ │
┌───────────┼───────────┼───────────┐
│ │ │ │
┌─────▼─────┐ ┌──▼──────┐ ┌──▼──────┐ ┌──▼──────┐
│ Site 01 │ │ Site 02 │ │ Site 03 │ │ Site N │
│ ┌──┐ ┌──┐ │ │ ┌──┐┌──┐│ │ ┌──┐┌──┐│ │ ┌──┐┌──┐│
│ │A │ │B │ │ │ │A ││B ││ │ │A ││B ││ │ │A ││B ││
│ └──┘ └──┘ │ │ └──┘└──┘│ │ └──┘└──┘│ │ └──┘└──┘│
└───────────┘ └─────────┘ └─────────┘ └─────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USERS["Users<br/>(HTTPS / LB)"]
subgraph CENTRAL["Central Cluster"]
NA["Node A<br/>Active"]
NB["Node B<br/>Standby"]
NA <--> NB
end
USERS --> NA
CENTRAL --> SITE01
CENTRAL --> SITE02
CENTRAL --> SITE03
CENTRAL --> SITEN
subgraph SITE01["Site 01"]
S01A["A<br/>Active"]
S01B["B<br/>Standby"]
end
subgraph SITE02["Site 02"]
S02A["A<br/>Active"]
S02B["B<br/>Standby"]
end
subgraph SITE03["Site 03"]
S03A["A<br/>Active"]
S03B["B<br/>Standby"]
end
subgraph SITEN["Site N"]
SNA["A<br/>Active"]
SNB["B<br/>Standby"]
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USERS dec
class CENTRAL proc
class NA,S01A,S02A,S03A,SNA start
class NB,S01B,S02B,S03B,SNB muted
class SITE01,SITE02,SITE03,SITEN warn
```
## Central Cluster Setup
@@ -39,27 +39,42 @@ Both endpoints use the same `Protocol`. EF Core migration renames `Configuration
The `DataConnectionActor` Reconnecting state is extended:
```
Connected
│ disconnect detected
Push bad quality to all subscribers
Retry active endpoint (5s interval)
│ failure
_consecutiveFailures++
├─ < FailoverRetryCount → retry same endpoint
├─ ≥ FailoverRetryCount AND backup exists
│ → dispose adapter, switch _activeEndpoint, reset counter
│ → create fresh adapter with other config
│ → attempt connect
└─ ≥ FailoverRetryCount AND no backup
→ keep retrying indefinitely (current behavior)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
C(["Connected"])
BQ["Push bad quality<br/>to all subscribers"]
RT["Retry active endpoint<br/>(5s interval)"]
INC["_consecutiveFailures++"]
BR{"Evaluate<br/>_consecutiveFailures"}
SAME["Retry same endpoint"]
FO["Failover<br/>- dispose adapter, switch _activeEndpoint, reset counter<br/>- create fresh adapter with other config<br/>- attempt connect"]
NB["Keep retrying indefinitely<br/>(current behavior)"]
RC(["On successful reconnect (either endpoint)<br/>1. Reset _consecutiveFailures = 0<br/>2. ReSubscribeAll() — re-create subscriptions on new adapter<br/>3. Transition to Connected<br/>4. Log failover event if endpoint changed<br/>5. Report active endpoint in health metrics"])
C -->|"disconnect detected"| BQ
BQ --> RT
RT -->|"failure"| INC
INC --> BR
BR -->|"&lt; FailoverRetryCount"| SAME
SAME -.->|"retry"| RT
BR -->|"&gt;= FailoverRetryCount AND backup exists"| FO
BR -->|"&gt;= FailoverRetryCount AND no backup"| NB
NB -.->|"retry (round-robin n/a)"| RT
FO -->|"connect succeeds"| RC
FO -.->|"connect fails (round-robin: primary to backup to primary...)"| RT
RC -->|"Transition to Connected"| C
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class C,RC start
class BQ,RT,SAME proc
class INC,BR dec
class FO warn
class NB bad
```
**On successful reconnect (either endpoint):**
@@ -32,33 +32,45 @@ We want a strongly-typed model for OPC UA endpoint configuration, a validator th
## Architecture
```
┌──────────────────────────────────────┐
│ ZB.MOM.WW.ScadaBridge.Commons │
│ Types/DataConnections/ │
OpcUaEndpointConfig.cs (POCO)
│ OpcUaHeartbeatConfig.cs (POCO) │
│ OpcUaSecurityMode.cs (enum) │
│ Validators/ │
│ OpcUaEndpointConfigValidator.cs │
│ Serialization/ │
│ OpcUaEndpointConfigSerializer.cs │
└──────────────────────────────────────┘
│ (referenced by both)
┌───────┴────────────────────────┐
▼ ▼
┌──────────────────────────┐ ┌────────────────────────────┐
│ ZB.MOM.WW.ScadaBridge.CentralUI │ │ ZB.MOM.WW.ScadaBridge.SiteRuntime │
│ Components/Forms/ │ │ Actors/ │
│ OpcUaEndpointEditor │ │ DeploymentManagerActor │
│ .razor (shared) │ │ (passes raw JSON to │
│ │ │ DataConnectionFactory)│
│ Pages/Admin/ │ │ │
│ DataConnectionForm │ │ DataConnections.OpcUa/ │
│ .razor │ │ OpcUaDataConnection.cs │
└──────────────────────────┘ │ (consumes typed model) │
└────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph COMMONS["ZB.MOM.WW.ScadaBridge.Commons"]
TYPES["Types/DataConnections/<br/>OpcUaEndpointConfig.cs (POCO)<br/>OpcUaHeartbeatConfig.cs (POCO)<br/>OpcUaSecurityMode.cs (enum)"]
VALID["Validators/<br/>OpcUaEndpointConfigValidator.cs"]
SER["Serialization/<br/>OpcUaEndpointConfigSerializer.cs"]
TYPES ~~~ VALID ~~~ SER
end
subgraph CENTRALUI["ZB.MOM.WW.ScadaBridge.CentralUI"]
CUIFORMS["Components/Forms/<br/>OpcUaEndpointEditor.razor (shared)"]
CUIPAGES["Pages/Admin/<br/>DataConnectionForm.razor"]
CUIFORMS ~~~ CUIPAGES
end
subgraph SITERUNTIME["ZB.MOM.WW.ScadaBridge.SiteRuntime"]
SRACTORS["Actors/<br/>DeploymentManagerActor<br/>(passes raw JSON to DataConnectionFactory)"]
SRDC["DataConnections.OpcUa/<br/>OpcUaDataConnection.cs<br/>(consumes typed model)"]
SRACTORS ~~~ SRDC
end
COMMONS -->|referenced by| CENTRALUI
COMMONS -->|referenced by| SITERUNTIME
NOTE["Both sides deserialize DataConnection.PrimaryConfiguration / BackupConfiguration<br/>into the same OpcUaEndpointConfig instance. The DB column type does not change."]
CENTRALUI -.- NOTE
SITERUNTIME -.- NOTE
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class COMMONS dec
class TYPES,VALID,SER warn
class CENTRALUI,CUIFORMS,CUIPAGES proc
class SITERUNTIME,SRACTORS,SRDC start
class NOTE muted
```
Both sides deserialize from `DataConnection.PrimaryConfiguration` / `BackupConfiguration` strings into the same `OpcUaEndpointConfig` instance. The DB column type does not change.
@@ -165,7 +165,7 @@ Five indexes with explicit names:
- `scadabridge_audit_writer`: GRANT INSERT ON AuditLog; GRANT SELECT ON AuditLog. (No UPDATE, no DELETE.)
- `scadabridge_audit_purger`: GRANT ALTER ON SCHEMA::dbo; GRANT SELECT ON AuditLog. (Enables ALTER PARTITION FUNCTION SWITCH and SWITCH PARTITION.)
- `Down()` drops indexes, table, scheme, function, then both roles.
- Create: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADALINK_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
- Create: `tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Migrations/AddAuditLogTableMigrationTests.cs` — uses a fixture connecting to the running `infra/mssql` container via the connection string in `infra/mssql/.env` (or skips with `Skip.If` when the env var `SCADABRIDGE_MSSQL_TEST_CONN` is unset, so CI without the container still passes).
Integration test assertions:
- `sys.partition_functions` contains `pf_AuditLog_Month`.
@@ -191,10 +191,10 @@ Integration test assertions:
**Notes for the implementer:**
- Use `Microsoft.Data.SqlClient` directly in the test fixture (not EF) to issue raw SQL for grant assertions.
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADALINK_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
- `Skip.If(string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SCADABRIDGE_MSSQL_TEST_CONN")), "MSSQL not available")` — keeps tests CI-safe.
- Test database name: `ScadaBridgeAuditMigrationTest_<guid>` (created per fixture, dropped on dispose).
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADALINK_MSSQL_TEST_CONN` is set; skip cleanly when unset.
**Bundle C acceptance:** Migration applied to a fresh test DB on the `infra/mssql` container creates the partition function/scheme/table/indexes/roles. Smoke test confirms UPDATE is denied for the writer role. All migration tests pass when `SCADABRIDGE_MSSQL_TEST_CONN` is set; skip cleanly when unset.
---
@@ -17,29 +17,8 @@ A sibling `docker-env2/` directory with `deploy.sh` / `teardown.sh` / `seed-site
## Architecture Overview
```
(host machine)
Primary stack (already existing — unchanged) Env2 stack (new)
┌────────────────────────────────────┐ ┌──────────────────────────────┐
│ Traefik :9000 ◄── 9001/9002 UI │ │ Traefik :9100 ◄── 9101/9102 UI│
│ Central A/B (9011/9012 Akka) │ │ Central A/B (9111/9112 Akka) │
│ Site-A/B/C (9021..9044) │ │ Site-X (9121/9122 Akka, │
└─────────────┬──────────────────────┘ │ 9123/9124 gRPC) │
│ └──────────┬───────────────────┘
│ │
▼ scadabridge-net (shared bridge network) ◄──────┘
┌──────────────────────────────────────────────────────────────┐
│ scadabridge-mssql ScadaBridgeConfig (primary DB) │
│ ScadaBridgeMachineData (primary DB) │
│ ScadaBridgeConfig2 (env2 DB) ← new │
│ ScadaBridgeMachineData2(env2 DB) ← new │
│ scadabridge-ldap (shared — same test users) │
│ scadabridge-smtp (shared Mailpit) │
│ scadabridge-opcua (shared) │
│ scadabridge-restapi (shared) │
└──────────────────────────────────────────────────────────────┘
```
![env2-architecture-overview](diagrams/env2-architecture-overview.png)
<!-- source: diagrams/env2-architecture-overview.drawio — edit, then re-export with export-drawio.sh -->
Both stacks attach to the same `scadabridge-net` Docker bridge so env2's app containers can reach the infra services by container hostname (`scadabridge-mssql`, `scadabridge-ldap`, etc.). Akka clusters are independent — each side's `SeedNodes` lists only its own central nodes, so they never gossip-merge despite sharing the network.
+41 -16
View File
@@ -14,18 +14,43 @@
## Task Dependency Graph
```
T0 ─┐ ┐
T1 ─┤ (all independent, all │
T2 ─┤ parallelizable, all ├─► T10 (manual smoke test)
T3 ─┤ ready from the start) │
T4 ─┤ │
T6 ─┤ │
T7 ─┤ │
T8 ─┤ │
T9 ─┘ │
T0,T4 ──► T5 (lifecycle scripts) ─────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
GRP["all independent, all parallelizable, all ready from the start"]
T0["T0"]
T1["T1"]
T2["T2"]
T3["T3"]
T4["T4"]
T6["T6"]
T7["T7"]
T8["T8"]
T9["T9"]
T5["T5<br/>lifecycle scripts"]
T10(["T10<br/>manual smoke test"])
NOTE["T10 is the only task that requires all of T0T9 done. Everything else runs in parallel."]
T0 --> T10
T1 --> T10
T2 --> T10
T3 --> T10
T6 --> T10
T7 --> T10
T8 --> T10
T9 --> T10
T0 --> T5
T4 --> T5
T5 --> T10
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class T0,T1,T2,T3,T4,T6,T7,T8,T9 proc
class T5 warn
class T10 start
class GRP,NOTE muted
```
T10 is the only task that requires all of T0T9 done. Everything else can run in parallel.
@@ -414,7 +439,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-a
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -431,7 +456,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-central-b
environment:
SCADALINK_CONFIG: Central
SCADABRIDGE_CONFIG: Central
ASPNETCORE_ENVIRONMENT: Development
ASPNETCORE_URLS: "http://+:5000"
ports:
@@ -448,7 +473,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-a
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9121:8082" # Akka remoting
- "9123:8083" # gRPC streaming
@@ -464,7 +489,7 @@ services:
image: scadabridge:latest
container_name: scadabridge-env2-site-x-b
environment:
SCADALINK_CONFIG: Site
SCADABRIDGE_CONFIG: Site
ports:
- "9122:8082" # Akka remoting
- "9124:8083" # gRPC streaming
+1 -1
View File
@@ -81,7 +81,7 @@ Exactly one of `content.json` or `content.enc` is present.
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:...",
"encryption": {
"algorithm": "AES-256-GCM",
+1 -1
View File
@@ -734,7 +734,7 @@ Tests:
**Step 2-5:** Run-fail → implement → run-pass → commit.
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaLinkVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
`ManifestBuilder` accepts: `sourceEnvironment, exportedBy, scadaBridgeVersion, encryption?, contents[], contentBytes` and returns a `BundleManifest` with `ContentHash = SHA-256(contentBytes)`.
`ManifestValidator.Validate(BundleManifest manifest, byte[] contentBytes)` returns a `ValidationResult` enum (`Ok | UnsupportedFormatVersion | ContentHashMismatch | MalformedManifest`).
@@ -18,26 +18,32 @@
## Section 1 — Architecture
```
[Blazor Server browser]
│ SignalR
[CentralUI: InstanceConfigure.razor]
│ opens
[CentralUI: <OpcUaBrowserDialog/>]
│ uses
[CentralUI: IOpcUaBrowseService] ── implementation calls
[CommunicationService.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(siteId, BrowseOpcUaNodeCommand)]
│ ClusterClient Ask, ManagementEnvelope { User, Command, CorrelationId }
[Site: CentralCommunicationActor → DataConnectionManagerActor]
│ dispatches to IBrowsableDataConnection (RealOpcUaClient)
[OPC UA server] ◄── OPC Foundation .NET SDK Browse service
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
N1["Blazor Server browser"]
N2["CentralUI: InstanceConfigure.razor"]
N3["CentralUI: OpcUaBrowserDialog component"]
N4["CentralUI: IOpcUaBrowseService"]
N5["CommunicationService.SendCommandToSiteAsync of BrowseOpcUaNodeResult (siteId, BrowseOpcUaNodeCommand)"]
N6["Site: CentralCommunicationActor → DataConnectionManagerActor"]
N7["OPC UA server"]
N1 -->|SignalR| N2
N2 -->|opens| N3
N3 -->|uses| N4
N4 -->|implementation calls| N5
N5 -->|"ClusterClient Ask<br/>ManagementEnvelope { User, Command, CorrelationId }"| N6
N6 -->|"dispatches to IBrowsableDataConnection (RealOpcUaClient)<br/>OPC Foundation .NET SDK Browse service"| N7
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class N1,N2,N3,N4 proc
class N5 alt
class N6 start
class N7 warn
```
Three slices, top-to-bottom:
@@ -164,24 +170,47 @@ Returning failure inside `BrowseOpcUaNodeResult` (rather than exceptions across
**Wire flow.**
```
CentralUI.OpcUaBrowseService.BrowseChildrenAsync(siteId, connId, parent)
→ CommunicationService.SendCommandToSiteAsync<BrowseOpcUaNodeResult>(
siteId,
new BrowseOpcUaNodeCommand(connId, parent))
ManagementEnvelope { User, Command, CorrelationId } over ClusterClient
Site: CentralCommunicationActor unwraps envelope
Site: DataConnectionManagerActor receives BrowseOpcUaNodeCommand
- Look up IDataConnection by Id
- if not found → ConnectionNotFound
- if !(conn is IBrowsableDataConnection) → NotBrowsable
- else await conn.BrowseChildrenAsync(ParentNodeId, ct)
- Catch ConnectionNotConnectedException → ConnectionNotConnected
- Catch OperationCanceledException → Timeout
- Catch ServiceResultException → ServerError + verbatim msg
- Else success: BrowseOpcUaNodeResult(children, truncated, null)
→ Reply travels back via CentralCommunicationActor → CommunicationService
→ returned to CentralUI page
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S1["CentralUI.OpcUaBrowseService.BrowseChildrenAsync(siteId, connId, parent)"]
S2["CommunicationService.SendCommandToSiteAsync of BrowseOpcUaNodeResult (siteId, new BrowseOpcUaNodeCommand(connId, parent))"]
S3["ManagementEnvelope { User, Command, CorrelationId }<br/>over ClusterClient"]
S4["Site: CentralCommunicationActor unwraps envelope"]
S5["Site: DataConnectionManagerActor receives BrowseOpcUaNodeCommand<br/>(DCL coordinator actor — owns the per-connection IDataConnection instances)"]
S1 --> S2 --> S3 --> S4 --> S5
subgraph HANDLER["Handler logic"]
direction TB
HL["Look up IDataConnection by Id"]
HNF["if not found → ConnectionNotFound"]
HNB["if not (conn is IBrowsableDataConnection) → NotBrowsable"]
HAW["else await conn.BrowseChildrenAsync(ParentNodeId, ct)"]
HNC["Catch ConnectionNotConnectedException → ConnectionNotConnected"]
HCN["Catch OperationCanceledException → Timeout"]
HSVC["Catch ServiceResultException → ServerError + verbatim msg"]
HSUC["Else success: BrowseOpcUaNodeResult(children, truncated, null)"]
HL --- HNF --- HNB --- HAW --- HNC --- HCN --- HSVC --- HSUC
end
S5 -->|processes| HANDLER
R1["Reply travels back via<br/>CentralCommunicationActor → CommunicationService"]
R2["returned to CentralUI page"]
HANDLER -->|result / failure| R1
R1 --> R2
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class S1,R1,R2 proc
class S2,S3 alt
class S4,S5,HSUC start
class HANDLER,HL,HAW dec
class HNF,HNB,HNC,HCN,HSVC bad
```
Handler lives in the **DCL coordinator actor** (the same actor that owns the per-connection `IDataConnection` instances) — keeps lifecycle and browse co-located so we don't race against reconnect.
+80 -16
View File
@@ -16,22 +16,86 @@
## Task dependency overview
```
T1 ─┬─ T2 ─┬─ T17 (computed AlarmActor enrich)
│ ├─ T18 (proto) ── T19 (grpc mapping) ── T23 (DebugView)
T3 ─┼─ T10 (DCL actor)
├─ T11 (OPC UA adapter)
└─ T12 (MxGateway adapter)
T4 ─┬─ T5 ── T6 ── T21 (mgmt handlers)
├─ T7 (migration)
├─ T8 ── T9 (validation)
└─ T20 ─┬─ T21 ── T26 (seed)
├─ T22 (CLI)
├─ T24 (template UI)
└─ T25 (instance UI)
T13, T14 ──┐
T1,T2,T3,T4(Resolved),T13,T14 ── T15 (NativeAlarmActor) ── T16 (InstanceActor wiring)
(everything) ── T27 (docs) , T28 (integration/manual verify)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
T1["T1"]
T3["T3"]
T2["T2"]
T10["T10<br/>DCL actor"]
T11["T11<br/>OPC UA adapter"]
T12["T12<br/>MxGateway adapter"]
T17["T17<br/>computed AlarmActor enrich"]
T18["T18<br/>proto"]
T19["T19<br/>grpc mapping"]
T23["T23<br/>DebugView"]
T4["T4"]
T5["T5"]
T6["T6"]
T7["T7<br/>migration"]
T8["T8"]
T9["T9<br/>validation"]
T20["T20"]
T21["T21<br/>mgmt handlers"]
T26["T26<br/>seed"]
T22["T22<br/>CLI"]
T24["T24<br/>template UI"]
T25["T25<br/>instance UI"]
T13["T13"]
T14["T14"]
T15["T15<br/>NativeAlarmActor"]
T16["T16<br/>InstanceActor wiring"]
T15IN["inputs to T15:<br/>T1, T2, T3, T4 (Resolved), T13, T14"]
T27["T27<br/>docs"]
T28["T28<br/>integration / manual verify"]
EVT["(everything) emits to T27 and T28"]
T1 --> T2
T1 --> T10
T1 --> T11
T1 --> T12
T3 --> T2
T3 --> T10
T3 --> T11
T3 --> T12
T2 --> T17
T2 --> T18
T18 --> T19
T19 --> T23
T4 --> T5
T4 --> T7
T4 --> T8
T4 --> T20
T5 --> T6
T6 --> T21
T8 --> T9
T20 --> T21
T20 --> T22
T20 --> T24
T20 --> T25
T21 --> T26
T13 --> T15
T14 --> T15
T15 --> T16
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class T1,T2,T3,T10,T11,T12 proc
class T17,T18,T19,T23 alt
class T4,T5,T6,T7,T8,T9,T20,T21,T22,T24,T25,T26 start
class T13,T14 dec
class T15,T16 warn
class T27,T28,T15IN,EVT muted
```
---
@@ -0,0 +1,123 @@
# Folder + Repo Rename `scadalink-design``ScadaBridge` — Design
**Date:** 2026-05-31
**Status:** Approved, ready for implementation planning
**Scope:** Rename the repo folder `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`, rename the Gitea repository `dohertj2/scadalink-design``dohertj2/ScadaBridge`, and scrub every residual `scadalink`/`ScadaLink` reference inside the repo to its `ScadaBridge` equivalent.
This reverses the earlier decision (in `2026-05-28-scadabridge-rename-design.md`) to leave the folder name diverged from the product name, and completes the residual reference scrub that the earlier effort deliberately left out (the `SCADALINK_*` runtime env vars and the production deploy script).
## Context
The product was already renamed from "ScadaLink" to "ScadaBridge" in the `2026-05-28` effort: the solution (`ZB.MOM.WW.ScadaBridge.slnx`), all `src/`/`tests/` projects, namespaces, MS SQL databases, docker containers/network/image, the CLI config dir (`~/.scadabridge/`), `CLAUDE.md`, and `README.md` already say ScadaBridge. What remained untouched were the runtime environment variables (`SCADALINK_*`), one `.NET` config-key convention (`ScadaLink__*`), a stale assembly-name reference in the production deploy script, a few cosmetic identifiers, and historical doc/code-review mentions.
## Decisions
| # | Decision | Selected option |
|---|----------|-----------------|
| 1 | Folder-name collision (`~/Desktop/ScadaBridge` already exists — a separate, older, non-git project) | Rename the existing folder aside to `~/Desktop/ScadaBridge-old` (non-destructive); nothing deleted |
| 2 | Reference scope | Full scrub — env vars, config keys, assembly name, SQL login, cosmetic identifiers, and historical docs/code-reviews |
| 3 | Env-var cutover style | Hard cutover (no backward-compat fallback); dev stack is redeployed |
| 4 | New env-var/identifier prefix | Short product name `SCADABRIDGE_` / `ScadaBridge__` (no `ZB.MOM.WW`), consistent with existing runtime artifacts (`scadabridge-*` containers, `~/.scadabridge/`) |
| 5 | Gitea server-side rename | User renames via the Gitea web UI; Claude updates the local `origin` remote and verifies |
| 6 | Execution mechanism | Scripted multi-pass `sed` over `git ls-files`, most-specific-first, with a completeness-gate `git grep` |
| 7 | Migration records | Carved out of the scrub so their before→after meaning survives (see §2) |
## Section 1 — Order of operations
1. **Safety check** — confirm the working tree is clean (commit/stash WIP first); record the current branch.
2. **Clear the collision**`mv ~/Desktop/ScadaBridge ~/Desktop/ScadaBridge-old`.
3. **Move the folder**`mv ~/Desktop/scadalink-design ~/Desktop/ScadaBridge`. Plain OS move; git tracks contents, not the repo-root directory name, so history is untouched. `.idea/` and `.claude/` ride along inside the moved folder.
4. **Scrub references** — run the scripted multi-pass `sed` (§2).
5. **Build + test gate**`dotnet build ZB.MOM.WW.ScadaBridge.slnx`, unit tests, completeness `git grep`.
6. **Commit** (§5).
7. **Runtime cutover** — rebuild + redeploy docker so running containers read the new env-var names (§4). No DB changes.
8. **Gitea rename** — user web-UI rename; Claude updates local `origin` + verifies fetch/push.
9. **Write/commit this design doc** (already produced by the brainstorm).
## Section 2 — The scrub
A new `tools/scrub-scadalink-refs.sh` operating on `git ls-files -z | xargs -0 sed -i ''` (skips `.git/` and `bin/obj` build artifacts), applied **most-specific-first** so a broad rule cannot double-replace an earlier result:
```
1. ScadaLink.Host.exe → ZB.MOM.WW.ScadaBridge.Host.exe (stale assembly name, deploy/install.ps1)
2. ScadaLink__ → ScadaBridge__ (.NET hierarchical config keys, deploy/install.ps1)
3. SCADALINK_ → SCADABRIDGE_ (13 runtime env vars)
4. scadaLinkVersion → scadaBridgeVersion (Transport param/local var — cosmetic)
5. scadalink_app → scadabridge_app (SQL login)
6. ScadaLink → ScadaBridge (residual)
7. scadalink → scadabridge (residual)
```
### The 13 `SCADALINK_*` env vars in scope
`SCADALINK_CONFIG`, `SCADALINK_FORMAT`, `SCADALINK_USERNAME`, `SCADALINK_PASSWORD`, `SCADALINK_MANAGEMENT_URL`, `SCADALINK_CONFIGURATIONDB_CONNECTION_STRING`, `SCADALINK_DESIGNTIME_CONNECTIONSTRING`, `SCADALINK_MSSQL_TEST_CONN`, `SCADALINK_PLAYWRIGHT_DB`, `SCADALINK_JWT_SIGNING_KEY`, `SCADALINK_LDAP_SERVICE_ACCOUNT_PASSWORD`, `SCADALINK_AUDIT_FILTER_4KB_P95_US`, `SCADALINK_AUDIT_FILTER_RAW_P95_US`.
They are read by code (`Environment.GetEnvironmentVariable(...)` in Host/CLI/tests) **and** set by `docker/docker-compose.yml`, `docker-env2/docker-compose.yml`, `appsettings.Central.json` (`${...}` placeholders), and `deploy/wonder-app-vd03/install.ps1`. All sites change together in one pass.
### Carved out (excluded — migration records)
Scrubbing these would destroy their before→after meaning:
- `tools/rename-to-scadabridge.sh` — prior rename tooling.
- `tools/scrub-scadalink-refs.sh` — this scrub script (its substitution rules are the mapping).
- `docker/rename-databases.sh` — DB rename helper (`ALTER DATABASE ScadaLinkConfig MODIFY NAME = ScadaBridgeConfig`, `ALTER LOGIN [scadalink_app] ...`).
- `docs/plans/2026-05-28-scadabridge-rename-design.md` — prior rename design.
- `docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md`**this document** (also a before→after record).
- `docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md` — the implementation plan (its substitution table and embedded script are the mapping).
### Completeness gate
Must return only the six carve-outs:
```bash
git grep -niE "scadalink" -- . \
':!tools/rename-to-scadabridge.sh' \
':!tools/scrub-scadalink-refs.sh' \
':!docker/rename-databases.sh' \
':!docs/plans/2026-05-28-scadabridge-rename-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md'
```
## Section 3 — Collision folder
The existing `~/Desktop/ScadaBridge` (non-git, older project containing `akka.md`, `netstd.md`, `CommentChecker`, its own `src/`/`tests/`/`tsdb/`) is renamed to `~/Desktop/ScadaBridge-old`. Nothing is deleted. If the user later confirms it is dead, they delete it themselves.
## Section 3a — Git-ignored `deploy/` tree (added during implementation)
`/deploy/` is git-ignored, so the `git grep`-based scrub script (and its completeness gate) structurally could not reach it — a gap surfaced by code review. The `deploy/wonder-app-vd03/` production artifacts (`install.ps1`, `appsettings.Central.json`, `appsettings.Site.json`, `RUNBOOK.md`, `ldap/glauth.cfg`) still held old references that would break against the renamed binary: `SCADALINK_*` env vars, `ScadaLink__*` config keys, the `"ScadaLink"` config-section root, `ScadaLink.Host.exe` (now `ZB.MOM.WW.ScadaBridge.Host.exe`), `scadalink.exe` (CLI `AssemblyName` is `scadabridge`), and `akka.tcp://scadalink@…` seed URIs (the actor system name in code is `scadabridge`).
Per user decision, this tree was scrubbed by hand with the **same** substitution set, **including** the internal LDAP directory domain (`dc=scadalink,dc=local``dc=scadabridge,dc=local` and `@scadalink.local``@scadabridge.local`) applied in lockstep across both `appsettings` and `glauth.cfg` so LDAP login stays consistent. Because `deploy/` is git-ignored, these edits are **local-only / not committed** — they fix the on-disk artifacts for the next `wonder-app-vd03` deploy. Verified clean with `grep -rniI scadalink deploy/`.
## Section 4 — Runtime cutover
Hard cutover. After the scrub, rebuild the image and `docker compose up -d --force-recreate` on both clusters (`docker/`, `docker-env2/`) so the running containers read `SCADABRIDGE_*`. **No database rename or wipe** — database names were already migrated in the `2026-05-28` effort and are unchanged here. If no stack is currently running, the cutover applies on the next `bash docker/deploy.sh`.
## Section 5 — Commits
Two staged commits on the current branch, `git diff`-reviewed between them:
1. `refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)` — the scrub + any build/test fixes.
2. `docs: add folder/repo rename design doc` — this document.
The folder `mv` and the Gitea rename are not git commits.
## Section 6 — Risks & rollback
- **Missed env var** → silent config-read failure. Mitigated by the completeness-gate grep + build/test + redeploy smoke check.
- **Folder move breaks editor/Claude session paths** — the concern that drove the `2026-05-28` "don't rename the folder" decision; accepted by the user this time.
- **Gitea redirect** — Gitea auto-redirects the old repo URL after rename, so pushes do not hard-fail even before the remote is updated; `origin` is updated regardless.
- **Rollback** — pre-scrub state is the last git commit (`git reset --hard`); folder moves reverse with `mv`.
## Section 7 — Verification
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` clean; unit tests green.
- Completeness grep returns only the four carve-outs.
- `git remote -v` shows `https://gitea.dohertylan.com/dohertj2/ScadaBridge.git`; `git ls-remote origin` / `git fetch` succeeds.
- (If a stack is running) CLI smoke against the redeployed cluster.
## Out of scope
- Renaming the MS SQL databases, docker containers/network, or CLI config dir — already done in the `2026-05-28` effort.
- Sister repos `~/Desktop/MxAccessGateway`, `~/Desktop/OtOpcUa`.
- Deleting `~/Desktop/ScadaBridge-old` — left to the user.
@@ -0,0 +1,349 @@
# Folder + Repo Rename `scadalink-design``ScadaBridge` Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Rename the repo folder `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`, rename the Gitea repository `dohertj2/scadalink-design``dohertj2/ScadaBridge`, and scrub every residual `scadalink`/`ScadaLink` reference inside the repo to its `ScadaBridge` equivalent.
**Architecture:** A scripted, most-specific-first `sed` pass over git-tracked text files does the content scrub; the folder rename is a plain OS `mv` (git tracks contents, not the repo-root directory name); the Gitea rename is a user web-UI action followed by a local `origin` URL update. Migration records (the prior rename tooling/design and the DB-rename helper) are carved out so their before→after meaning survives. Hard env-var cutover, no DB changes.
**Tech Stack:** bash 3.2 (macOS), BSD `sed -i ''`, git, `git grep`, dotnet (`ZB.MOM.WW.ScadaBridge.slnx`), docker compose.
**Design doc:** `docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md`
> **CRITICAL — path change after Task 0:** Task 0 moves the repo. From Task 1 onward, the repo root is `/Users/dohertj2/Desktop/ScadaBridge` (NOT `scadalink-design`). Every command below `cd`s there.
---
### Task 0: Safety checkpoint, clear the collision, move the folder
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:** (filesystem operations only — no file edits)
- Move: `~/Desktop/ScadaBridge``~/Desktop/ScadaBridge-old`
- Move: `~/Desktop/scadalink-design``~/Desktop/ScadaBridge`
**Step 1: Confirm the working tree is clean and record the branch**
Run:
```bash
cd /Users/dohertj2/Desktop/scadalink-design && git status --short && git branch --show-current
```
Expected: no output from `git status --short` (clean tree) and a branch name (e.g. `main`). If there is uncommitted work, STOP and ask the user whether to commit or stash before proceeding.
**Step 2: Verify both move endpoints are safe**
Run:
```bash
ls -ld /Users/dohertj2/Desktop/ScadaBridge /Users/dohertj2/Desktop/ScadaBridge-old /Users/dohertj2/Desktop/scadalink-design 2>&1
```
Expected: `ScadaBridge` exists (the old non-git project), `scadalink-design` exists, and `ScadaBridge-old` does **not** exist (`No such file or directory`). If `ScadaBridge-old` already exists, STOP and ask the user for a different aside-name.
**Step 3: Move the existing ScadaBridge aside, then move the repo into place**
Run:
```bash
mv /Users/dohertj2/Desktop/ScadaBridge /Users/dohertj2/Desktop/ScadaBridge-old
mv /Users/dohertj2/Desktop/scadalink-design /Users/dohertj2/Desktop/ScadaBridge
```
Expected: no output (success).
**Step 4: Verify the move and that git still works in the new location**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git status --short && git log --oneline -1
```
Expected: clean tree; the HEAD commit is the design-doc commit (`docs: add folder/repo rename design doc ...`). Confirms git history survived the folder move.
**Step 5: No commit** — filesystem moves are not git changes. Proceed to Task 1.
**Acceptance:** `~/Desktop/ScadaBridge` is the git repo (clean tree, intact history); `~/Desktop/ScadaBridge-old` holds the former non-git project; `~/Desktop/scadalink-design` no longer exists.
---
### Task 1: Create the scrub script
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:**
- Create: `/Users/dohertj2/Desktop/ScadaBridge/tools/scrub-scadalink-refs.sh`
**Step 1: Write the script**
Create `tools/scrub-scadalink-refs.sh` with exactly this content:
```bash
#!/usr/bin/env bash
# One-time scrub of residual ScadaLink/scadalink references → ScadaBridge.
# Operates on git-tracked TEXT files only (git grep -I skips binaries),
# minus carve-out migration records whose before→after meaning must survive.
# Substitutions are applied most-specific-first so a broad rule cannot
# double-replace an earlier result. Idempotent: re-running is a no-op.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# Carve-outs (migration records): prior rename tooling/design, the DB-rename
# helper, this script itself, and the two rename design docs.
EXCLUDES_RE='^(tools/rename-to-scadabridge\.sh|tools/scrub-scadalink-refs\.sh|docker/rename-databases\.sh|docs/plans/2026-05-28-scadabridge-rename-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-design\.md)$'
files=()
while IFS= read -r f; do
[[ "$f" =~ $EXCLUDES_RE ]] && continue
files+=("$f")
done < <(git grep -liI 'scadalink' -- .)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files to scrub."
exit 0
fi
printf 'Scrubbing %d file(s):\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
sed -i '' \
-e 's/ScadaLink\.Host\.exe/ZB.MOM.WW.ScadaBridge.Host.exe/g' \
-e 's/ScadaLink__/ScadaBridge__/g' \
-e 's/SCADALINK_/SCADABRIDGE_/g' \
-e 's/scadaLinkVersion/scadaBridgeVersion/g' \
-e 's/scadalink_app/scadabridge_app/g' \
-e 's/ScadaLink/ScadaBridge/g' \
-e 's/scadalink/scadabridge/g' \
"${files[@]}"
echo "Done."
```
**Step 2: Make it executable**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && chmod +x tools/scrub-scadalink-refs.sh
```
Expected: no output.
**Step 3: Dry-run preview (list candidate files WITHOUT editing)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -liI 'scadalink' -- . | grep -Ev '^(tools/rename-to-scadabridge\.sh|tools/scrub-scadalink-refs\.sh|docker/rename-databases\.sh|docs/plans/2026-05-28-scadabridge-rename-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-design\.md)$'
```
Expected: a list of ~27 tracked source/config/test/doc files (the same set surfaced in the design doc), and NONE of the five carve-outs. No binary files (e.g. `site_events.db`) appear. If a carve-out appears, the script's `EXCLUDES_RE` is wrong — fix before running.
**Step 4: No commit yet** — the script is committed together with the scrub in Task 4.
**Acceptance:** `tools/scrub-scadalink-refs.sh` exists, is executable, and the dry-run lists only the intended files.
---
### Task 2: Run the scrub and pass the completeness gate
**Classification:** high-risk
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:** (the script edits ~27 tracked files in place; see Task 1 dry-run)
**Step 1: Run the scrub**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash tools/scrub-scadalink-refs.sh
```
Expected: `Scrubbing N file(s):` followed by the file list and `Done.`
**Step 2: Completeness gate — only the five carve-outs may still match**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -niI 'scadalink' -- . \
':!tools/rename-to-scadabridge.sh' \
':!tools/scrub-scadalink-refs.sh' \
':!docker/rename-databases.sh' \
':!docs/plans/2026-05-28-scadabridge-rename-design.md' \
':!docs/plans/2026-05-31-folder-repo-rename-scadabridge-design.md'
```
Expected: **no output** (zero hits outside the carve-outs). If any line prints, inspect it — it is either a new case variant the substitution list missed (add a rule and re-run) or a file that should have been carved out.
**Step 3: Sanity-check the new env-var names landed**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git grep -nE 'SCADABRIDGE_CONFIG|ScadaBridge__|scadabridge_app|ZB\.MOM\.WW\.ScadaBridge\.Host\.exe' -- src/ deploy/ docker/ docker-env2/ | head
```
Expected: hits showing `SCADABRIDGE_CONFIG` in compose/Host, `ScadaBridge__` and `ZB.MOM.WW.ScadaBridge.Host.exe` in `deploy/wonder-app-vd03/install.ps1`.
**Step 4: Review the diff**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git diff --stat
```
Expected: ~27 files changed, all content-only. Spot-check `git diff docker/docker-compose.yml src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` shows clean `SCADALINK_``SCADABRIDGE_` swaps.
**Step 5: No commit yet** — commit after the build/test gate (Task 4).
**Acceptance:** completeness gate returns zero hits outside the five carve-outs; new names present in code/config; diff is content-only.
---
### Task 3: Build, run unit tests, fix any stragglers
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:** (no planned edits; only stragglers surfaced by the build, if any)
**Step 1: Restore + build the solution**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet build ZB.MOM.WW.ScadaBridge.slnx 2>&1 | tail -20
```
Expected: `Build succeeded` with 0 errors. The scrub only renamed env-var string literals, a method parameter (`scadaLinkVersion``scadaBridgeVersion`), comments, and docs — no type/namespace changes — so the build should be clean. If an error appears (e.g. a parameter referenced by name elsewhere), fix that single reference and rebuild.
**Step 2: Run the CLI + Transport + ConfigurationDatabase unit tests (the projects whose code changed)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet test tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ 2>&1 | tail -25
```
Expected: all tests pass. These projects assert on the env-var name string literals (`SCADABRIDGE_FORMAT`, `SCADABRIDGE_USERNAME`, …) and the `scadaBridgeVersion` parameter — they were scrubbed in lockstep with the code, so they stay consistent. If a test references an env var the code no longer sets (or vice-versa), reconcile the pair.
**Step 3: No commit yet** — commit in Task 4.
**Acceptance:** `dotnet build` clean; the changed projects' unit tests green.
---
### Task 4: Commit the scrub
**Classification:** trivial
**Estimated implement time:** ~1 min
**Parallelizable with:** none
**Files:** (commit only)
**Step 1: Stage and commit**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git add -A && git commit -m "refactor: scrub residual ScadaLink refs → ScadaBridge (env vars, config keys, assembly name, SQL login)
Renames the 13 SCADALINK_* runtime env vars → SCADABRIDGE_*, the ScadaLink__
.NET config keys → ScadaBridge__, the stale ScadaLink.Host.exe assembly name
→ ZB.MOM.WW.ScadaBridge.Host.exe, the scadalink_app SQL login → scadabridge_app,
and residual identifiers/comments/docs. Migration records (prior rename
tooling/design, DB-rename helper, this scrub script) carved out.
Adds tools/scrub-scadalink-refs.sh."
```
Expected: commit succeeds; `~28` files changed (the 27 scrubbed + the new script).
**Step 2: Verify**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git log --oneline -2 && git status --short
```
Expected: the refactor commit on top of the design-doc commit; clean tree.
**Acceptance:** scrub committed; clean working tree.
---
### Task 5: Update the local Git remote after the Gitea web-UI rename
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 6
**Files:** (git remote config only)
**Step 1: Manual gate — user renames the repo in the Gitea web UI**
Ask the user to rename the repository at `https://gitea.dohertylan.com/dohertj2/scadalink-design` to `ScadaBridge` via **Settings → Repository Name**, and to confirm when done. Do NOT proceed until confirmed.
**Step 2: Point `origin` at the new URL**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git remote set-url origin https://gitea.dohertylan.com/dohertj2/ScadaBridge.git && git remote -v
```
Expected: both fetch and push lines show `.../dohertj2/ScadaBridge.git`.
**Step 3: Verify the remote is reachable and refs match**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git ls-remote origin HEAD
```
Expected: prints a commit SHA for `HEAD` (auth via macOS keychain succeeds). If it fails with not-found, the web-UI rename has not propagated — re-confirm with the user.
**Step 4: Push the new commits**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && git push origin HEAD
```
Expected: push succeeds; the design-doc + scrub commits land on the renamed remote.
**Acceptance:** `origin` points at `dohertj2/ScadaBridge`; `git ls-remote` and `git push` succeed.
---
### Task 6: Runtime cutover — rebuild + redeploy so containers read the new env vars (conditional)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:** (no edits — runtime only)
**Step 1: Check whether a stack is running**
Run:
```bash
docker ps --format '{{.Names}}' | grep -E 'scadabridge|central|site' || echo "no stack running"
```
Expected: either a list of running containers, or `no stack running`. **If `no stack running`, SKIP this task** — the new env-var names apply automatically on the next `bash docker/deploy.sh`. Note this to the user and mark the task done.
**Step 2: Rebuild the image and recreate the primary cluster**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash docker/deploy.sh 2>&1 | tail -30
```
Expected: image rebuilds; containers recreated. (`deploy.sh` already force-recreates.) Containers now receive `SCADABRIDGE_*` from the scrubbed `docker/docker-compose.yml`.
**Step 3: Recreate the second cluster (if it was running)**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge && bash docker-env2/deploy.sh 2>&1 | tail -30
```
Expected: env2 cluster recreated from the scrubbed `docker-env2/docker-compose.yml`.
**Step 4: Smoke-test readiness + CLI**
Run:
```bash
sleep 20
curl -s http://localhost:9000/health/ready && echo
cd /Users/dohertj2/Desktop/ScadaBridge && dotnet run --project src/ZB.MOM.WW.ScadaBridge.CLI -- --username multi-role --password password template list 2>&1 | tail -15
```
Expected: `/health/ready` reports healthy; the CLI returns a template list (proves the Host read its config via the renamed `SCADABRIDGE_*` env vars). No DB rename/wipe is needed — database names are unchanged by this effort.
**Acceptance:** either the stack is confirmed not-running (skipped), or the redeployed cluster is healthy and the CLI smoke passes against the new env-var names.
---
## Execution Notes
- Tasks are strictly sequential 0 → 1 → 2 → 3 → 4, then 5 and 6 may run in parallel (both depend only on 4).
- The completeness gate in Task 2 and the build/test gate in Task 3 are the safety net for a missed reference.
- Rollback: `git reset --hard HEAD~1` (or `~2`) undoes the scrub commit; `mv` reverses the folder moves; `git remote set-url` reverts the remote.
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan.md",
"tasks": [
{"id": 0, "subject": "Task 0: Safety checkpoint, clear collision, move folder", "status": "completed"},
{"id": 1, "subject": "Task 1: Create the scrub script", "status": "completed", "blockedBy": [0]},
{"id": 2, "subject": "Task 2: Run scrub + completeness gate", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Build, run unit tests, fix stragglers", "status": "completed", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: Commit the scrub", "status": "completed", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Update local Git remote after Gitea web-UI rename", "status": "completed", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Runtime cutover redeploy (conditional)", "status": "completed", "blockedBy": [4]}
],
"lastUpdated": "2026-06-01T02:05:03Z"
}
@@ -0,0 +1,96 @@
<mxfile host="app.diagrams.net">
<diagram id="env2arch" name="Env2 Architecture">
<mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1100" pageHeight="900" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Host machine container -->
<mxCell id="host" value="host machine" style="rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#666666;verticalAlign=top;fontStyle=2;fontColor=#666666;dashed=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="1020" height="800" as="geometry" />
</mxCell>
<!-- Primary stack -->
<mxCell id="primary" value="Primary stack&#10;(already existing — unchanged)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;verticalAlign=top;fontStyle=1;align=center;spacingTop=6;" vertex="1" parent="1">
<mxGeometry x="80" y="100" width="420" height="220" as="geometry" />
</mxCell>
<mxCell id="p-traefik" value="Traefik :9000 ◄── 9001/9002 UI" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="160" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="p-central" value="Central A/B (9011/9012 Akka)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="210" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="p-site" value="Site-A/B/C (9021..9044)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="100" y="260" width="380" height="40" as="geometry" />
</mxCell>
<!-- Env2 stack -->
<mxCell id="env2" value="Env2 stack&#10;(new)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;verticalAlign=top;fontStyle=1;align=center;spacingTop=6;" vertex="1" parent="1">
<mxGeometry x="600" y="100" width="420" height="220" as="geometry" />
</mxCell>
<mxCell id="e-traefik" value="Traefik :9100 ◄── 9101/9102 UI" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="160" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="e-central" value="Central A/B (9111/9112 Akka)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="210" width="380" height="40" as="geometry" />
</mxCell>
<mxCell id="e-site" value="Site-X (9121/9122 Akka, 9123/9124 gRPC)" style="whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="620" y="260" width="380" height="40" as="geometry" />
</mxCell>
<!-- Shared network bar -->
<mxCell id="net" value="scadabridge-net (shared bridge network)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontStyle=1;" vertex="1" parent="1">
<mxGeometry x="80" y="400" width="940" height="50" as="geometry" />
</mxCell>
<!-- Infra container -->
<mxCell id="infra" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;verticalAlign=top;" vertex="1" parent="1">
<mxGeometry x="120" y="500" width="860" height="300" as="geometry" />
</mxCell>
<!-- MSSQL block -->
<mxCell id="mssql" value="scadabridge-mssql" style="shape=cylinder3;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;verticalAlign=top;fontStyle=1;spacingTop=4;" vertex="1" parent="1">
<mxGeometry x="150" y="530" width="160" height="240" as="geometry" />
</mxCell>
<mxCell id="db1" value="ScadaBridgeConfig (primary DB)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="540" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db2" value="ScadaBridgeMachineData (primary DB)" style="whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="590" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db3" value="ScadaBridgeConfig2 (env2 DB) ← new" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="640" width="300" height="40" as="geometry" />
</mxCell>
<mxCell id="db4" value="ScadaBridgeMachineData2 (env2 DB) ← new" style="whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="340" y="690" width="300" height="40" as="geometry" />
</mxCell>
<!-- Shared commodity infra services -->
<mxCell id="ldap" value="scadabridge-ldap&#10;(shared — same test users)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="530" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="smtp" value="scadabridge-smtp&#10;(shared Mailpit)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="580" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="opcua" value="scadabridge-opcua (shared)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="630" width="280" height="40" as="geometry" />
</mxCell>
<mxCell id="restapi" value="scadabridge-restapi (shared)" style="whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;align=left;spacingLeft=8;" vertex="1" parent="1">
<mxGeometry x="680" y="680" width="280" height="40" as="geometry" />
</mxCell>
<!-- Edges: primary -> net, env2 -> net -->
<mxCell id="ep" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#6c8ebf;" edge="1" parent="1" source="primary" target="net">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="ee" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#82b366;" edge="1" parent="1" source="env2" target="net">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- net -> infra -->
<mxCell id="eni" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=block;strokeColor=#d6b656;" edge="1" parent="1" source="net" target="infra">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

+20 -20
View File
@@ -547,26 +547,26 @@ This section governs how implementation plans are executed. The goal is autonomo
For each work package, follow this sequence:
```
┌─────────────────────────────────────────────────────┐
│ 1. READ the WP description and acceptance criteria │
│ 2. READ all traced requirements (HLR bullets, KDD, │
CD constraints) to understand intent
3. IMPLEMENT the WP
│ - Write code │
│ - Write unit tests for acceptance criteria │
│ - Write negative tests for prohibition criteria │
│ 4. VERIFY acceptance criteria │
│ - Run tests: all must pass │
│ - Walk each acceptance criterion line by line │
│ - If a criterion cannot be verified yet (depends │
│ on a later WP), note it as "deferred to WP-N" │
│ 5. UPDATE the phase execution checklist │
│ - Mark WP as complete with date │
│ - Note any deferred criteria │
│ - Note any questions logged │
│ 6. COMMIT with message: "Phase N WP-M: <summary>" │
└─────────────────────────────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S1["1. READ the WP description and acceptance criteria"]
S2["2. READ all traced requirements (HLR bullets, KDD, CD constraints) to understand intent"]
S3["3. IMPLEMENT the WP<br/>• Write code<br/>• Write unit tests for acceptance criteria<br/>• Write negative tests for prohibition criteria"]
S4["4. VERIFY acceptance criteria<br/>• Run tests: all must pass<br/>• Walk each acceptance criterion line by line<br/>• If a criterion cannot be verified yet (depends on a later WP), note it as deferred to WP-N"]
S5["5. UPDATE the phase execution checklist<br/>• Mark WP as complete with date<br/>• Note any deferred criteria<br/>• Note any questions logged"]
S6["6. COMMIT with message:<br/>Phase N WP-M: summary"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class S1,S2 proc
class S3 start
class S4,S5 dec
class S6 warn
```
### Mid-Phase Compliance Check
+73 -56
View File
@@ -23,28 +23,38 @@ gRPC server-streaming is an established pattern for real-time tag value updates;
## Architecture
```
Central Cluster Site Cluster
───────────── ────────────
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph CENTRAL["Central Cluster"]
BT["DebugStreamBridgeActor"]
GC["SiteStreamGrpcClient<br/>(per-site, on central)"]
BB["DebugStreamBridgeActor"]
SR(["SignalR Hub / Blazor UI"])
end
DebugStreamBridgeActor InstanceActor
│ │
│── SubscribeDebugView ──► │ (ClusterClient: command/control)
│◄── DebugViewSnapshot ── │
│ │
│ │ publishes AttributeValueChanged
│ │ publishes AlarmStateChanged
│ ▼
SiteStreamGrpcClient ◄──── gRPC stream ───── SiteStreamGrpcServer
(per-site, on central) (HTTP/2) (Kestrel, on site)
│ │
│ reads from gRPC stream │ receives from SiteStreamManager
│ routes by correlationId │ filters by instance name
▼ │
DebugStreamBridgeActor │
│ │
▼ │
SignalR Hub / Blazor UI │
subgraph SITE["Site Cluster"]
IN["InstanceActor"]
PB{"publishes<br/>AttributeValueChanged<br/>AlarmStateChanged"}
GS["SiteStreamGrpcServer<br/>(Kestrel, on site)"]
end
BT -.->|"SubscribeDebugView"| IN
IN -.->|"DebugViewSnapshot"| BT
IN --> PB
PB --> GS
GS -->|"gRPC stream (HTTP/2)"| GC
GC --> BB
BB --> SR
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class BT,GC,BB proc
class SR start
class IN,GS warn
class PB dec
```
**Key separation**: ClusterClient handles subscribe/unsubscribe/snapshot (request-response). gRPC handles the ongoing value stream (server-streaming).
@@ -271,16 +281,22 @@ public override async Task SubscribeInstance(
`IServerStreamWriter<T>` is **not thread-safe**. Multiple Akka actors may publish events concurrently. The `Channel<SiteStreamEvent>` bridges these worlds:
```
Akka Actor Thread(s) gRPC Response Stream
│ ▲
│ channel.Writer.TryWrite(evt) │ await responseStream.WriteAsync(evt)
▼ │
┌─────────────────────────────────────────┐
│ Channel<SiteStreamEvent> │
│ BoundedChannelOptions(1000) │
│ FullMode = DropOldest │
└─────────────────────────────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
AKKA["Akka Actor Thread(s)"]
CH(["Channel&lt;SiteStreamEvent&gt;<br/><br/>BoundedChannelOptions(1000)<br/>FullMode = DropOldest"])
GRPC["gRPC Response Stream"]
AKKA -->|"channel.Writer.TryWrite(evt)"| CH
CH -->|"await responseStream.WriteAsync(evt)"| GRPC
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
class AKKA warn
class CH start
class GRPC proc
```
- **Bounded capacity** (1000): prevents unbounded memory growth if the gRPC client is slow
@@ -431,31 +447,32 @@ private void HandleGrpcStreamError(Exception ex)
### Reconnection State Machine (DebugStreamBridgeActor)
```
┌──────────────────┐
│ Streaming │ ◄── Normal state: gRPC stream active
└────────┬─────────┘
│ gRPC stream error / keepalive timeout
┌──────────────────┐
┌──► │ Reconnecting │ ── try other node endpoint
│ └────────┬─────────┘
│ │
│ ┌────────┴─────────┐
│ │ │
│ success failure (retry < max)
│ │ │
│ ▼ │
│ Streaming schedule retry (5s backoff)
│ │
└───────────────────────┘
failure (retry >= max)
┌──────────────────┐
│ Terminated │ ── notify consumer, stop actor
└──────────────────┘
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
S(["Streaming<br/><i>Normal state: gRPC stream active</i>"])
R(["Reconnecting<br/><i>try other node endpoint</i>"])
D{"reconnect result?"}
RT["schedule retry<br/>(5s backoff)"]
T(["Terminated<br/><i>notify consumer, stop actor</i>"])
S -->|"gRPC stream error / keepalive timeout"| R
R --> D
D -->|"success"| S
D -->|"failure (retry &lt; max)"| RT
RT --> R
D -->|"failure (retry &gt;= max)"| T
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class S start
class R dec
class D proc
class RT warn
class T bad
```
### Summary
+3 -3
View File
@@ -310,9 +310,9 @@ Configuration is resolved in the following priority order (highest wins):
1. **Command-line options**: `--url`, `--username`, `--password`, `--format`.
2. **Environment variables**:
- `SCADALINK_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
- `SCADALINK_FORMAT` — Default output format (`json` or `table`).
- `SCADALINK_USERNAME` / `SCADALINK_PASSWORD` — LDAP credentials. Preferred over
- `SCADABRIDGE_MANAGEMENT_URL` — Management API URL (e.g., `http://central-host:5000`).
- `SCADABRIDGE_FORMAT` — Default output format (`json` or `table`).
- `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD` — LDAP credentials. Preferred over
`--password` on the command line, which is visible in process listings and shell
history. Credentials are never read from the config file.
3. **Configuration file**: `~/.scadabridge/config.json` — Persistent defaults for management URL and output format only (never credentials).
+47 -12
View File
@@ -167,19 +167,54 @@ Keepalive settings are configurable via `CommunicationOptions`:
## Topology
```
Central Cluster
├── ClusterClient → Site A Cluster (SiteCommunicationActor via Receptionist) [command/control]
├── ClusterClient → Site B Cluster (SiteCommunicationActor via Receptionist) [command/control]
└── ClusterClient → Site N Cluster (SiteCommunicationActor via Receptionist) [command/control]
├── SiteStreamGrpcClient ◄── gRPC stream ── Site A (SiteStreamGrpcServer) [real-time data]
├── SiteStreamGrpcClient ◄── gRPC stream ── Site B (SiteStreamGrpcServer) [real-time data]
└── SiteStreamGrpcClient ◄── gRPC stream ── Site N (SiteStreamGrpcServer) [real-time data]
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart LR
subgraph Central["Central Cluster"]
CCA["ClusterClient<br/>(command/control)"]
CCB["ClusterClient<br/>(command/control)"]
CCN["ClusterClient<br/>(command/control)"]
GRPCC["SiteStreamGrpcClient<br/>(real-time data)"]
end
Site Clusters
└── ClusterClient → Central Cluster (CentralCommunicationActor via Receptionist) [command/control]
└── SiteStreamGrpcServer (Kestrel HTTP/2, port 8083) → serves gRPC streams [real-time data]
subgraph SiteA["Site A Cluster"]
SACOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SAGRPC["SiteStreamGrpcServer<br/>(Kestrel HTTP/2, port 8083)"]
SACC["ClusterClient to Central<br/>(CentralCommunicationActor)"]
end
subgraph SiteB["Site B Cluster"]
SBCOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SBGRPC["SiteStreamGrpcServer"]
end
subgraph SiteN["Site N Cluster"]
SNCOMM["SiteCommunicationActor<br/>(via Receptionist)"]
SNGRPC["SiteStreamGrpcServer"]
end
CCA -->|command/control| SACOMM
CCB -->|command/control| SBCOMM
CCN -->|command/control| SNCOMM
SAGRPC -->|"gRPC stream (real-time data)"| GRPCC
SBGRPC -->|gRPC stream| GRPCC
SNGRPC -->|gRPC stream| GRPCC
SACC -.->|command/control| Central
NOTE["Sites do NOT communicate with each other.<br/>All inter-cluster communication flows through Central."]
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class CCA,CCB,CCN,SACOMM,SACC,SBCOMM,SNCOMM dec
class GRPCC,SAGRPC,SBGRPC,SNGRPC start
class NOTE muted
class Central proc
class SiteA,SiteB,SiteN alt
```
- Sites do **not** communicate with each other.
@@ -143,14 +143,32 @@ EF Core's DbContext naturally provides unit-of-work semantics:
### Example Transactional Flow
```
Template Engine: Create Template
├── repository.AddTemplate(template) // template is a Commons POCO
├── repository.AddAttributes(attributes) // attributes are Commons POCOs
├── repository.AddAlarms(alarms) // alarms are Commons POCOs
├── repository.AddScripts(scripts) // scripts are Commons POCOs
└── repository.SaveChangesAsync() // single transaction commits all
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
start(["Template Engine: Create Template"])
add1["repository.AddTemplate(template)<br/>// template is a Commons POCO"]
add2["repository.AddAttributes(attributes)<br/>// attributes are Commons POCOs"]
add3["repository.AddAlarms(alarms)<br/>// alarms are Commons POCOs"]
add4["repository.AddScripts(scripts)<br/>// scripts are Commons POCOs"]
save["repository.SaveChangesAsync()<br/>// single transaction commits all"]
db[("Configuration DB<br/>(MS SQL)")]
start --> add1
add1 --> add2
add2 --> add3
add3 --> add4
add4 --> save
save -. "single transaction" .-> db
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class start start
class add1,add2,add3,add4 proc
class save dec
class db muted
```
---
@@ -184,13 +202,30 @@ Audit entries are written **synchronously** within the same database transaction
### Integration Example
```
Template Engine: Update Template
├── repository.UpdateTemplate(template)
├── auditService.LogAsync(user, "Update", "Template", template.Id,
template.Name, template)
└── repository.SaveChangesAsync() ← both the change and audit entry commit together
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
start(["Template Engine: Update Template"])
upd["repository.UpdateTemplate(template)"]
audit["auditService.LogAsync(user, &quot;Update&quot;, &quot;Template&quot;,<br/>template.Id, template.Name, template)"]
save["repository.SaveChangesAsync()"]
note["both the change and audit entry<br/>commit together"]
start --> upd
upd --> audit
audit --> save
save -.- note
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class start start
class upd proc
class audit alt
class save dec
class note warn
```
### Audit Entry Schema
@@ -80,11 +80,38 @@ Data connections support an optional backup endpoint for automatic failover when
**Failover state machine:**
```
Connected → disconnect → push bad quality → retry active endpoint (5s)
→ N failures (≥ FailoverRetryCount) → switch to other endpoint
→ dispose adapter, create fresh adapter with other config
→ reconnect → ReSubscribeAll → Connected
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
connected(["Connected"])
pushbad["push bad quality"]
retry["retry active endpoint<br/>(5s)"]
decide{"N failures<br/>(≥ FailoverRetryCount)?"}
switch["switch to other endpoint"]
dispose["dispose adapter,<br/>create fresh adapter<br/>with other config"]
reconnect["reconnect"]
resub["ReSubscribeAll"]
connected -->|disconnect| pushbad
pushbad --> retry
retry --> decide
decide -->|"no (retry again)"| retry
decide -->|yes| switch
switch --> dispose
dispose --> reconnect
reconnect --> resub
resub -->|back to Connected| connected
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class connected start
class pushbad bad
class retry,reconnect,resub proc
class decide dec
class switch,dispose warn
```
- **Round-robin**: primary → backup → primary → backup. No preferred endpoint after first failover — the connection stays on whichever endpoint is working.
@@ -22,23 +22,46 @@ Central cluster only. The site-side deployment responsibilities (receiving confi
## Deployment Flow
```
Engineer (UI) → Deployment Manager (Central)
├── 1. Request validated + flattened config from Template Engine
│ (validation includes flattening, script compilation,
│ trigger references, connection binding completeness)
├── 2. If validation fails → return errors to UI, stop
├── 3. Send config to site via Communication Layer
│ │
│ ▼
│ Site Runtime (Deployment Manager Singleton)
│ ├── 4. Store new flattened config locally (SQLite)
│ ├── 5. Compile scripts at site
│ ├── 6. Create/update Instance Actor (with child Script + Alarm Actors)
│ └── 7. Report success/failure back to central
└── 8. Update deployment status in config DB
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
engineer(["Engineer (UI)"])
subgraph DMC["Deployment Manager (Central)"]
step1["1. Request validated and flattened config from Template Engine<br/>(validation: flattening, script compilation, trigger references,<br/>connection binding completeness)"]
step2{"2. Validation fails?"}
step2fail(["Return errors to UI, stop"])
step3["3. Send config to site via Communication Layer"]
step8[("8. Update deployment status in config DB")]
end
subgraph SR["Site Runtime (Deployment Manager Singleton)"]
step4[("4. Store new flattened config locally (SQLite)")]
step5["5. Compile scripts at site"]
step6["6. Create/update Instance Actor<br/>(with child Script + Alarm Actors)"]
step7["7. Report success/failure back to central"]
end
engineer --> step1
step1 --> step2
step2 -->|yes| step2fail
step2 -->|no| step3
step3 -->|config| step4
step4 --> step5
step5 --> step6
step6 --> step7
step7 -. "report success/failure" .-> step8
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class engineer start
class step1,step5,step6,step7 dec
class step2,step2fail bad
class step3 dec
class step8 proc
class step4 start
```
## Deployment Identity & Idempotency
+34 -13
View File
@@ -123,19 +123,40 @@ API method scripts are compiled at central startup — all method definitions ar
## Request Flow
```
External System
Inbound API (Central)
├── 1. Extract API key from request
├── 2. Validate key exists and is enabled
├── 3. Resolve method by name
├── 4. Check API key is in method's approved list
├── 5. Validate and deserialize parameters
├── 6. Execute implementation script (subject to method timeout)
├── 7. Serialize return value
└── 8. Return response
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
ext(["External System"])
api["Inbound API (Central)"]
s1["1. Extract API key from request"]
s2["2. Validate key exists and is enabled"]
s3["3. Resolve method by name"]
s4["4. Check API key is in method's approved list"]
s5["5. Validate and deserialize parameters"]
s6["6. Execute implementation script<br/>(subject to method timeout)"]
s7["7. Serialize return value"]
s8["8. Return response"]
ext --> api
api --> s1
s1 --> s2
s2 --> s3
s3 --> s4
s4 --> s5
s5 --> s6
s6 --> s7
s7 --> s8
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class ext start
class api proc
class s1,s2,s3,s4,s5,s7 dec
class s6 alt
class s8 warn
```
## Implementation Script Capabilities
@@ -24,23 +24,39 @@ SMTP and HTTP delivery is blocking I/O. Delivery work runs on a **dedicated bloc
## End-to-End Flow
```
Site script: Notify.To("list").Send(subject, body)
│ generate NotificationId (GUID) locally; return it to the script immediately
Site Store-and-Forward Engine (notification category, target = central)
│ durably forwards to central via CentralSite Communication (ClusterClient);
│ buffers/retries if central is unreachable
Central ingest: insert-if-not-exists on NotificationId → Notifications table (Pending)
│ ack the site → site S&F clears the message
Central Notification Outbox actor (singleton, active central node)
│ polls due rows; resolves the list; delivers via the matching adapter
├── success → Delivered
├── transient failure → Retrying (schedule NextAttemptAt)
└── permanent failure
/ retries exhausted → Parked
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
SCRIPT(["Site script: Notify.To('list').Send(subject, body)<br/>generate NotificationId (GUID) locally;<br/>return it to the script immediately"])
SNF["Site Store-and-Forward Engine<br/>(notification category, target = central)<br/>durably forwards to central via Central-Site Communication<br/>(ClusterClient); buffers/retries if central is unreachable"]
INGEST[("Central ingest: insert-if-not-exists on NotificationId<br/>to Notifications table (Pending)<br/>ack the site, site S and F clears the message")]
OUTBOX["Central Notification Outbox actor<br/>(singleton, active central node)<br/>polls due rows; resolves the list;<br/>delivers via the matching adapter"]
D1{Delivery outcome}
DELIVERED(["Delivered"])
RETRYING["Retrying<br/>(schedule NextAttemptAt)"]
PARKED(["Parked"])
SCRIPT --> SNF
SNF --> INGEST
INGEST --> OUTBOX
OUTBOX --> D1
D1 -->|success| DELIVERED
D1 -->|transient failure| RETRYING
D1 -->|"permanent failure /<br/>retries exhausted"| PARKED
RETRYING -.->|retry due| OUTBOX
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
class SCRIPT,DELIVERED start
class SNF warn
class INGEST proc
class OUTBOX alt
class D1,RETRYING dec
class PARKED bad
```
The site forwards only `(listName, subject, body)` plus provenance — recipient resolution happens at central, at delivery time. This keeps notification-list definitions in one place and removes the deploy-to-sites artifact entirely.
+50 -14
View File
@@ -27,20 +27,56 @@ Site clusters only.
## Actor Hierarchy
```
Deployment Manager Singleton (Cluster Singleton)
├── Instance Actor ("MachineA-001")
│ ├── Script Actor ("MonitorSpeed") — coordinator
│ │ └── Script Execution Actor — short-lived, per invocation
│ ├── Script Actor ("CalculateOEE") — coordinator
│ │ └── Script Execution Actor — short-lived, per invocation
│ ├── Alarm Actor ("OverTemp") — coordinator (computed)
│ │ └── Alarm Execution Actor — short-lived, per on-trigger invocation
├── Alarm Actor ("LowPressure") — coordinator (computed)
└── Native Alarm Actor ("OpcUaServer1") — read-only mirror, peer to Alarm Actor
├── Instance Actor ("MachineA-002")
│ └── ...
└── ...
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
DMS["Deployment Manager Singleton<br/>(Cluster Singleton)"]
IA1["Instance Actor<br/>('MachineA-001')"]
IA2["Instance Actor<br/>('MachineA-002')"]
IAMORE["… more Instance Actors"]
SA1["Script Actor ('MonitorSpeed')<br/>— coordinator"]
SA2["Script Actor ('CalculateOEE')<br/>— coordinator"]
AA1["Alarm Actor ('OverTemp')<br/>— coordinator (computed)"]
AA2["Alarm Actor ('LowPressure')<br/>— coordinator (computed)"]
NAA1["Native Alarm Actor ('OpcUaServer1')<br/>— read-only mirror, peer to Alarm Actor"]
SEA1["Script Execution Actor<br/>— short-lived, per invocation"]
SEA2["Script Execution Actor<br/>— short-lived, per invocation"]
AEA1["Alarm Execution Actor<br/>— short-lived, per on-trigger invocation"]
IA2CHILD["… (Script / Alarm Actors)"]
DMS --> IA1
DMS --> IA2
DMS -.-> IAMORE
IA1 --> SA1
IA1 --> SA2
IA1 --> AA1
IA1 --> AA2
IA1 --> NAA1
SA1 --> SEA1
SA2 --> SEA2
AA1 --> AEA1
IA2 -.-> IA2CHILD
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class DMS proc
class IA1,IA2 start
class SA1,SA2 dec
class AA1,AA2 bad
class NAA1 alt
class SEA1,SEA2,AEA1 warn
class IAMORE,IA2CHILD muted
```
---
+22 -16
View File
@@ -25,22 +25,28 @@ Site clusters only. The central cluster does not buffer messages.
## Message Lifecycle
```
Script submits message
Attempt immediate delivery
├── Success → Remove from buffer
└── Failure → Buffer message
Retry loop (per retry policy)
├── Success → Remove from buffer + notify standby
└── Max retries exhausted → Park message
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
A([Script submits message]) --> B[Attempt immediate delivery]
B --> C{Delivered?}
C -->|Success| D([Remove from buffer])
C -->|Failure| E[Buffer message]
E --> F[Retry loop<br/>per retry policy]
F --> G{Retry outcome}
G -->|Success| H([Remove from buffer<br/>+ notify standby])
G -->|Max retries exhausted| I([Park message<br/>dead-letter])
classDef ok fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef buf fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef bad fill:#f8cecc,stroke:#b85450,color:#111111;
class A,D,H ok
class B,F proc
class C,G dec
class E buf
class I bad
```
For notifications, "delivery" means forwarding the message to the central cluster via CentralSite Communication; "success" is central's ack, on which the message is cleared. Notifications are retried at the fixed forward interval until central acks, but — like every other category — they are bounded by the engine's `DefaultMaxRetries` cap: a sustained central outage that exceeds `DefaultMaxRetries × forward-interval` will park the buffered notification, after which an operator can Retry/Discard it via the parked-message UI. Operationally, the cap is sized so the normal central-recovery window stays well inside it; "do not park" is the design's operational intent on the happy path, not an absolute invariant. Callers that genuinely require unbounded retry pass `maxRetries: 0` on `EnqueueAsync` (the documented "no limit" escape hatch — see `StoreAndForward-015`).
+101 -59
View File
@@ -53,7 +53,7 @@ Exactly one of `content.json` or `content.enc` is present.
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:...",
"encryption": {
"algorithm": "AES-256-GCM",
@@ -92,19 +92,31 @@ The manifest is plaintext so the import wizard can preview bundle contents and s
## Architecture
```
ZB.MOM.WW.ScadaBridge.Transport
├── IBundleExporter
│ ExportAsync(ExportSelection, Passphrase?, ct) → Stream
├── IBundleImporter
LoadAsync(stream, Passphrase?, ct) → BundleSession
│ PreviewAsync(sessionId, ct) → ImportPreview
│ ApplyAsync(sessionId, resolutions, ct) → ImportResult
├── DependencyResolver
├── BundleSerializer (manifest + content JSON; ZIP packer)
├── BundleSecretEncryptor (AES-256-GCM + PBKDF2)
├── BundleSessionStore (in-memory, TTL'd)
└── ManifestValidator (schema/version gating, hash check)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
subgraph T["ZB.MOM.WW.ScadaBridge.Transport"]
EXPORTER["IBundleExporter<br/>ExportAsync(ExportSelection, Passphrase?, ct) → Stream"]
IMPORTER["IBundleImporter<br/>LoadAsync(stream, Passphrase?, ct) → BundleSession<br/>PreviewAsync(sessionId, ct) → ImportPreview<br/>ApplyAsync(sessionId, resolutions, ct) → ImportResult"]
RESOLVER["DependencyResolver"]
SERIALIZER["BundleSerializer<br/>(manifest + content JSON; ZIP packer)"]
ENCRYPTOR["BundleSecretEncryptor<br/>(AES-256-GCM + PBKDF2)"]
SESSIONSTORE["BundleSessionStore<br/>(in-memory, TTL'd)"]
MANIFESTVALIDATOR["ManifestValidator<br/>(schema/version gating, hash check)"]
end
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class EXPORTER,IMPORTER proc
class RESOLVER,SERIALIZER start
class ENCRYPTOR alt
class SESSIONSTORE warn
class MANIFESTVALIDATOR dec
class T muted
```
The component is central-only. It is registered in `ZB.MOM.WW.ScadaBridge.Host` for central roles only, never for site roles. All persistence flows through existing audited repository interfaces in `ZB.MOM.WW.ScadaBridge.ConfigurationDatabase` — the component does not call `DbContext.SaveChangesAsync` directly. `BundleSessionStore` is in-process on the active central node (matching Blazor Server circuit affinity): 30-minute TTL, eviction on expiry, 3-strike passphrase lockout per session.
@@ -132,21 +144,49 @@ The user can toggle "include all dependencies" off (with a warning that the bund
### Backend
```
User (Design role) ─► Central UI Export wizard
IBundleExporter
├─► DependencyResolver ─► repositories (read)
├─► EntitySerializer ─► content.json
├─► BundleSecretEncryptor ► content.enc (if passphrase)
├─► ManifestBuilder ─► manifest.json
ZIP packer → temp file → browser download
IAuditService.LogAsync(BundleExported …)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Design role)"])
WIZARD["Central UI Export wizard"]
EXPORTER["IBundleExporter"]
RESOLVER["DependencyResolver"]
REPOS[("repositories (read)")]
SERIALIZER["EntitySerializer"]
CONTENTJSON["content.json"]
ENCRYPTOR["BundleSecretEncryptor"]
CONTENTENC["content.enc<br/>(if passphrase)"]
MANIFESTBUILDER["ManifestBuilder"]
MANIFESTJSON["manifest.json"]
ZIP["ZIP packer → temp file → browser download"]
AUDIT["IAuditService.LogAsync(BundleExported …)"]
USER --> WIZARD
WIZARD --> EXPORTER
EXPORTER --> RESOLVER
RESOLVER --> SERIALIZER
SERIALIZER --> ENCRYPTOR
ENCRYPTOR --> MANIFESTBUILDER
MANIFESTBUILDER --> ZIP
ZIP --> AUDIT
RESOLVER --> REPOS
SERIALIZER --> CONTENTJSON
ENCRYPTOR --> CONTENTENC
MANIFESTBUILDER --> MANIFESTJSON
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER,AUDIT start
class WIZARD,EXPORTER,ZIP proc
class RESOLVER,SERIALIZER,MANIFESTBUILDER dec
class ENCRYPTOR alt
class CONTENTJSON,CONTENTENC,MANIFESTJSON warn
class REPOS muted
```
Audit event: `BundleExported` — caller, artifact count, content hash, encrypted yes/no, bundle filename.
@@ -179,34 +219,36 @@ Bundle references that cannot be satisfied in either the bundle or the target DB
### Backend
```
User (Admin role) ─► uploads bundle
IBundleImporter.LoadAsync
· verify SHA-256 (manifest vs content)
· check bundleFormatVersion supported
· decrypt content.enc with passphrase (if encrypted)
· deserialize entities
· open BundleSession (30-min TTL)
PreviewAsync → diff vs target DB → ImportPreview
▼ (user reviews + resolves conflicts)
ApplyAsync (single EF transaction)
· run two-tier semantic validation (minimal name scan + full SemanticValidator)
· apply resolutions (add / overwrite / skip / rename)
· upsert TemplateFolder hierarchy
· IAuditService.LogAsync(BundleImported …)
· commit
ImportResult → UI step 5
"View on Deployments →" (existing page)
```mermaid
%%{init: {'theme':'base', 'themeVariables': {'textColor':'#111111','lineColor':'#555555','edgeLabelBackground':'#ffffff','fontSize':'15px'}}}%%
flowchart TD
USER(["User (Admin role) → uploads bundle"])
LOAD["IBundleImporter.LoadAsync<br/>· verify SHA-256 (manifest vs content)<br/>· check bundleFormatVersion supported<br/>· decrypt content.enc with passphrase (if encrypted)<br/>· deserialize entities<br/>· open BundleSession (30-min TTL)"]
PREVIEW["PreviewAsync → diff vs target DB → ImportPreview"]
REVIEW["(user reviews + resolves conflicts)"]
APPLY["ApplyAsync (single EF transaction)<br/>· run two-tier semantic validation<br/>&nbsp;&nbsp;(minimal name scan + full SemanticValidator)<br/>· apply resolutions (add / overwrite / skip / rename)<br/>· upsert TemplateFolder hierarchy<br/>· IAuditService.LogAsync(BundleImported …)<br/>· commit"]
RESULT["ImportResult → UI step 5"]
DEPLOYMENTS["'View on Deployments →' (existing page)"]
USER --> LOAD
LOAD --> PREVIEW
PREVIEW --> APPLY
PREVIEW -.- REVIEW
APPLY --> RESULT
RESULT --> DEPLOYMENTS
classDef start fill:#d5e8d4,stroke:#82b366,color:#111111;
classDef proc fill:#dae8fc,stroke:#6c8ebf,color:#111111;
classDef dec fill:#fff2cc,stroke:#d6b656,color:#111111;
classDef warn fill:#ffe6cc,stroke:#d79b00,color:#111111;
classDef alt fill:#e1d5e7,stroke:#9673a6,color:#111111;
classDef muted fill:#f5f5f5,stroke:#999999,color:#666666;
class USER start
class LOAD,RESULT proc
class PREVIEW dec
class APPLY alt
class DEPLOYMENTS warn
class REVIEW muted
```
Authorization: `RequireAdmin` on both the Razor page and `IBundleImporter.*` entrypoints.
@@ -325,7 +367,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
"createdAtUtc": "2026-05-24T12:34:56Z",
"sourceEnvironment": "dev-cluster-a",
"exportedBy": "alice@corp.example",
"scadaLinkVersion": "1.4.2",
"scadaBridgeVersion": "1.4.2",
"contentHash": "sha256:abc123...",
"encryption": {
"algorithm": "AES-256-GCM",
@@ -371,7 +413,7 @@ The `manifest.json` file is always present in the ZIP root and is never encrypte
| `createdAtUtc` | ISO-8601 UTC timestamp of when the export was created. |
| `sourceEnvironment` | The `SourceEnvironment` name of the exporting cluster (from `TransportOptions`). Displayed in the import wizard and required to be retyped at the confirm step. |
| `exportedBy` | Authenticated username of the person who performed the export. |
| `scadaLinkVersion` | Application version of the exporting node. Used for diagnostic display only. |
| `scadaBridgeVersion` | Application version of the exporting node. Used for diagnostic display only. |
| `contentHash` | `sha256:<hex>` — SHA-256 of the raw `content.json` or `content.enc` bytes (pre-encryption). Verified on upload before any decryption. |
| `encryption` | Present only when a passphrase was supplied. Contains the KDF parameters and the per-bundle random salt and IV needed to re-derive the key and decrypt. Omitted for plaintext bundles. |
| `encryption.algorithm` | Always `"AES-256-GCM"` in v1. |
+1 -1
View File
@@ -531,7 +531,7 @@ Sites log operational events locally, including:
- The CLI sends user credentials via HTTP Basic Auth. The server authenticates against **LDAP/AD** and resolves roles before dispatching commands to the ManagementActor.
- CLI commands mirror all Management Service operations: templates, instances, sites, data connections, deployments, external systems, notifications, security (API keys and role mappings), audit log queries, and health status.
- Output is **JSON by default** (machine-readable, suitable for scripting) with an optional `--format table` flag for human-readable tabular output.
- Configuration is resolved from command-line options, **environment variables** (`SCADALINK_MANAGEMENT_URL`, `SCADALINK_FORMAT`), or a **configuration file** (`~/.scadabridge/config.json`).
- Configuration is resolved from command-line options, **environment variables** (`SCADABRIDGE_MANAGEMENT_URL`, `SCADABRIDGE_FORMAT`), or a **configuration file** (`~/.scadabridge/config.json`).
- The CLI is a separate executable from the Host binary — it is deployed on any machine with HTTP access to a central node.
## 14. General Conventions
+2
View File
@@ -16,6 +16,8 @@
</packageSource>
<packageSource key="dohertj2-gitea">
<package pattern="ZB.MOM.WW.MxGateway.*" />
<package pattern="ZB.MOM.WW.Health" />
<package pattern="ZB.MOM.WW.Health.*" />
</packageSource>
</packageSourceMapping>
<!--
+6 -6
View File
@@ -13,14 +13,14 @@ public class CliConfig
public string DefaultFormat { get; set; } = "json";
/// <summary>
/// LDAP username from the <c>SCADALINK_USERNAME</c> environment variable, if set.
/// LDAP username from the <c>SCADABRIDGE_USERNAME</c> environment variable, if set.
/// Credentials are intentionally only sourced from environment variables (or the
/// command line) — never from the config file — so they are not persisted to disk.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// LDAP password from the <c>SCADALINK_PASSWORD</c> environment variable, if set.
/// LDAP password from the <c>SCADABRIDGE_PASSWORD</c> environment variable, if set.
/// Provides a safer alternative to <c>--password</c>, which leaks into process
/// listings and shell history.
/// </summary>
@@ -69,20 +69,20 @@ public class CliConfig
}
// Override from environment variables
var envUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
var envUrl = Environment.GetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL");
if (!string.IsNullOrEmpty(envUrl))
config.ManagementUrl = envUrl;
var envFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
var envFormat = Environment.GetEnvironmentVariable("SCADABRIDGE_FORMAT");
if (!string.IsNullOrEmpty(envFormat))
config.DefaultFormat = envFormat;
// Credentials from environment variables only (never the config file).
var envUsername = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var envUsername = Environment.GetEnvironmentVariable("SCADABRIDGE_USERNAME");
if (!string.IsNullOrEmpty(envUsername))
config.Username = envUsername;
var envPassword = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
var envPassword = Environment.GetEnvironmentVariable("SCADABRIDGE_PASSWORD");
if (!string.IsNullOrEmpty(envPassword))
config.Password = envPassword;
@@ -76,7 +76,7 @@ public static class AuditCommandHelpers
if (string.IsNullOrWhiteSpace(url))
{
return AuditConnection.Fail(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
}
@@ -93,7 +93,7 @@ public static class AuditCommandHelpers
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return AuditConnection.Fail(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
}
@@ -51,7 +51,7 @@ internal static class CommandHelpers
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
@@ -65,14 +65,14 @@ internal static class CommandHelpers
}
// Resolve credentials: command-line options take precedence, then the
// SCADALINK_USERNAME / SCADALINK_PASSWORD environment variables.
// SCADABRIDGE_USERNAME / SCADABRIDGE_PASSWORD environment variables.
var username = ResolveCredential(result.GetValue(usernameOption), config.Username);
var password = ResolveCredential(result.GetValue(passwordOption), config.Password);
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
@@ -57,7 +57,7 @@ public static class DebugCommands
if (string.IsNullOrWhiteSpace(url))
{
OutputFormatter.WriteError(
"No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"No management URL specified. Use --url, set SCADABRIDGE_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadabridge/config.json.",
"NO_URL");
return 1;
}
@@ -76,7 +76,7 @@ public static class DebugCommands
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
OutputFormatter.WriteError(
"Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.",
"Credentials required. Use --username/--password or set SCADABRIDGE_USERNAME/SCADABRIDGE_PASSWORD.",
"NO_CREDENTIALS");
return 1;
}
+5 -5
View File
@@ -15,7 +15,7 @@ The output binary is `scadabridge` (or `scadabridge.exe` on Windows).
Every command requires a connection to a running Central node. The management URL can be supplied three ways, evaluated in this priority order:
1. `--url` flag on the command line
2. `SCADALINK_MANAGEMENT_URL` environment variable
2. `SCADABRIDGE_MANAGEMENT_URL` environment variable
3. `managementUrl` field in `~/.scadabridge/config.json`
```sh
@@ -57,10 +57,10 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi
| Variable | Description |
|----------|-------------|
| `SCADALINK_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADALINK_FORMAT` | Default output format (overrides config file) |
| `SCADALINK_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADALINK_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
| `SCADABRIDGE_MANAGEMENT_URL` | Management API URL (overrides config file) |
| `SCADABRIDGE_FORMAT` | Default output format (overrides config file) |
| `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) |
| `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. |
## Output
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// Factory for creating DbContext instances at design time (used by dotnet ef tooling).
/// Resolves the connection string from the Host's appsettings files, or — for environments
/// where those files are not present — from the
/// <c>SCADALINK_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
/// <c>SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING</c> environment variable.
/// </summary>
/// <remarks>
/// There is deliberately no hardcoded fallback connection string. A credential literal in
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
/// </remarks>
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<ScadaBridgeDbContext>
{
private const string EnvironmentVariableName = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
private const string EnvironmentVariableName = "SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING";
private const string ConfigurationKey = "ScadaBridge:Database:ConfigurationDb";
/// <summary>
@@ -1,45 +0,0 @@
using Akka.Cluster;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// Health check that returns healthy only if this node is the active (leader) node
/// in the Akka.NET cluster. Used by Traefik to route traffic to the active node.
/// </summary>
public class ActiveNodeHealthCheck : IHealthCheck
{
private readonly AkkaHostedService _akkaService;
/// <summary>Initializes a new <see cref="ActiveNodeHealthCheck"/> with the given Akka hosted service.</summary>
/// <param name="akkaService">The Akka hosted service providing access to the actor system and cluster state.</param>
public ActiveNodeHealthCheck(AkkaHostedService akkaService)
{
_akkaService = akkaService;
}
/// <summary>Returns healthy if this node is the cluster leader (active node); otherwise returns unhealthy.</summary>
/// <param name="context">Health check context providing registration details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var system = _akkaService.ActorSystem;
if (system == null)
return Task.FromResult(HealthCheckResult.Unhealthy("ActorSystem not yet available."));
var cluster = Cluster.Get(system);
var self = cluster.SelfMember;
if (self.Status != MemberStatus.Up)
return Task.FromResult(HealthCheckResult.Unhealthy($"Node not Up (status: {self.Status})."));
var leader = cluster.State.Leader;
if (leader != null && leader == self.Address)
return Task.FromResult(HealthCheckResult.Healthy("Active node (cluster leader)."));
return Task.FromResult(HealthCheckResult.Unhealthy("Standby node (not cluster leader)."));
}
}
@@ -1,52 +0,0 @@
using Akka.Cluster;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// Health check that verifies this node is an active member of the Akka.NET cluster.
/// Returns healthy only if the node's self-member status is Up or Joining.
/// </summary>
public class AkkaClusterHealthCheck : IHealthCheck
{
private readonly AkkaHostedService _akkaService;
/// <summary>
/// Initializes the health check with the Akka hosted service.
/// </summary>
/// <param name="akkaService">The hosted service providing access to the Akka actor system.</param>
public AkkaClusterHealthCheck(AkkaHostedService akkaService)
{
_akkaService = akkaService;
}
/// <summary>
/// Checks that this node is an active member of the Akka.NET cluster.
/// </summary>
/// <param name="context">Health check context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var system = _akkaService.ActorSystem;
if (system == null)
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
var cluster = Cluster.Get(system);
var status = cluster.SelfMember.Status;
var result = status switch
{
MemberStatus.Up or MemberStatus.Joining =>
HealthCheckResult.Healthy($"Akka cluster member status: {status}"),
MemberStatus.Leaving or MemberStatus.Exiting =>
HealthCheckResult.Degraded($"Akka cluster member status: {status}"),
_ =>
HealthCheckResult.Unhealthy($"Akka cluster member status: {status}")
};
return Task.FromResult(result);
}
}
@@ -1,43 +0,0 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
namespace ZB.MOM.WW.ScadaBridge.Host.Health;
/// <summary>
/// Health check that verifies database connectivity for Central nodes.
/// </summary>
public class DatabaseHealthCheck : IHealthCheck
{
private readonly ScadaBridgeDbContext _dbContext;
/// <summary>
/// Initializes a new <see cref="DatabaseHealthCheck"/>.
/// </summary>
/// <param name="dbContext">The EF Core database context used to test connectivity.</param>
public DatabaseHealthCheck(ScadaBridgeDbContext dbContext)
{
_dbContext = dbContext;
}
/// <summary>
/// Checks database connectivity by attempting to open a connection.
/// </summary>
/// <param name="context">Health check context providing failure status information.</param>
/// <param name="cancellationToken">Cancellation token for the check.</param>
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var canConnect = await _dbContext.Database.CanConnectAsync(cancellationToken);
return canConnect
? HealthCheckResult.Healthy("Database connection is available.")
: HealthCheckResult.Unhealthy("Database connection failed.");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Database connection failed.", ex);
}
}
}
+41 -25
View File
@@ -1,5 +1,6 @@
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
using ZB.MOM.WW.Health.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.AuditLog;
using ZB.MOM.WW.ScadaBridge.CentralUI;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
@@ -22,9 +23,9 @@ using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using Serilog;
// SCADALINK_CONFIG determines which role-specific config to load (Central or Site)
// SCADABRIDGE_CONFIG determines which role-specific config to load (Central or Site)
// DOTNET_ENVIRONMENT/ASPNETCORE_ENVIRONMENT stay as "Development" for dev tooling (static assets, EF migrations, etc.)
var scadabridgeConfig = Environment.GetEnvironmentVariable("SCADALINK_CONFIG")
var scadabridgeConfig = Environment.GetEnvironmentVariable("SCADABRIDGE_CONFIG")
?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT")
?? "Production";
@@ -110,16 +111,37 @@ try
?? throw new InvalidOperationException("ScadaBridge:Database:ConfigurationDb connection string is required for Central role.");
builder.Services.AddConfigurationDatabase(configDbConnectionString);
// WP-12: Health checks for readiness gating
// WP-12: Health checks for readiness gating — shared ZB.MOM.WW.Health probes.
// Check names and the ready/active tier split are preserved: database + akka-cluster
// carry the Ready tag (/health/ready), active-node carries the Active tag (/health/active).
// The Akka checks resolve ActorSystem from DI via the transient bridge registered below;
// the DatabaseHealthCheck<TContext> resolves a scoped ScadaBridgeDbContext (no factory).
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<AkkaClusterHealthCheck>("akka-cluster")
.AddCheck<ActiveNodeHealthCheck>("active-node");
.AddTypeActivatedCheck<DatabaseHealthCheck<ScadaBridgeDbContext>>(
"database",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready })
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
"akka-cluster",
failureStatus: null,
tags: new[] { ZbHealthTags.Ready },
args: AkkaClusterStatusPolicy.Default)
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
"active-node",
failureStatus: null,
tags: new[] { ZbHealthTags.Active });
// WP-13: Akka.NET bootstrap via hosted service
builder.Services.AddSingleton<AkkaHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem from DI. ScadaBridge owns the
// ActorSystem inside AkkaHostedService (not a DI singleton), so bridge it as TRANSIENT: each
// resolve re-reads the current value — null while warming up (checks → Degraded), live after.
// The factory must NOT throw: GetService<ActorSystem>() must return null (not raise) pre-start.
builder.Services.AddTransient<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().ActorSystem!);
// InboundAPI-022: register the production IActiveNodeGate implementation so
// standby-node gating is actually enforced (the InboundApiEndpointFilter
// consults IActiveNodeGate and defaults to "allow" when none is registered,
@@ -214,23 +236,17 @@ try
&& HttpMethods.IsPost(ctx.Request.Method),
branch => branch.UseAuditWriteMiddleware());
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
// REQ-HOST-4a defines readiness as cluster membership + DB connectivity,
// explicitly NOT cluster leadership. The leader-only "active-node" check is
// excluded here so a fully operational standby central node reports ready;
// leadership is reported separately on /health/active.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Name != "active-node",
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// Active node endpoint — returns 200 only on the cluster leader; used by Traefik for routing
app.MapHealthChecks("/health/active", new HealthCheckOptions
{
Predicate = check => check.Name == "active-node",
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// WP-12: Map the canonical three-tier health endpoints in one call:
// /health/ready — Ready-tagged checks (database + akka-cluster). REQ-HOST-4a defines
// readiness as cluster membership + DB connectivity, explicitly NOT
// cluster leadership, so the leader-only active-node check is excluded
// (a fully operational standby central node still reports ready).
// /health/active — Active-tagged check (active-node); returns 200 only on the cluster
// leader; used by Traefik for routing.
// /healthz — bare process liveness; runs no checks (always 200 while the process
// is up). New tier added by adopting the shared library.
// All three are anonymous and use the canonical ZbHealthWriter JSON output.
app.MapZbHealth();
app.MapStaticAssets();
app.MapCentralUI<ZB.MOM.WW.ScadaBridge.Host.Components.App>();
@@ -9,7 +9,7 @@
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"ASPNETCORE_ENVIRONMENT": "Development",
"SCADALINK_CONFIG": "Central"
"SCADABRIDGE_CONFIG": "Central"
}
},
"ScadaBridge Site": {
@@ -19,7 +19,7 @@
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"ASPNETCORE_ENVIRONMENT": "Development",
"SCADALINK_CONFIG": "Site"
"SCADABRIDGE_CONFIG": "Site"
}
}
}
@@ -73,6 +73,13 @@ public static class SiteServiceRegistration
services.AddSingleton<AkkaHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
// The shared ZB.MOM.WW.Health Akka checks resolve ActorSystem from DI. ScadaBridge owns the
// ActorSystem inside AkkaHostedService (not a DI singleton), so bridge it as TRANSIENT: each
// resolve re-reads the current value — null while warming up (checks → Degraded), live after.
// The factory must NOT throw: GetService<ActorSystem>() must return null (not raise) pre-start.
services.AddTransient<Akka.Actor.ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().ActorSystem!);
// Cluster node status provider for health reports
services.AddSingleton<IClusterNodeProvider>(sp =>
{
@@ -16,7 +16,6 @@
<PackageReference Include="Akka.Cluster.Tools" />
<PackageReference Include="Akka.Hosting" />
<PackageReference Include="Akka.Remote.Hosting" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@@ -29,6 +28,9 @@
<!-- Transitive override: Akka.Hosting 1.5.62 pins OpenTelemetry.Api 1.9.0 which is flagged
(GHSA-g94r-2vxg-569j, GHSA-8785-wc3w-h8q6). Bumping directly clears both advisories. -->
<PackageReference Include="OpenTelemetry.Api" />
<PackageReference Include="ZB.MOM.WW.Health" />
<PackageReference Include="ZB.MOM.WW.Health.Akka" />
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
</ItemGroup>
<ItemGroup>
@@ -20,7 +20,7 @@
},
"_secrets": "Host-003: Secrets are NOT committed in this file. Supply them via environment variables, which the Host's configuration builder (AddEnvironmentVariables) overlays over this file. Required: ScadaBridge__Database__ConfigurationDb, ScadaBridge__Security__LdapServiceAccountPassword, ScadaBridge__Security__JwtSigningKey. The ${...} placeholders below are intentionally non-functional and must be overridden per environment.",
"Database": {
"ConfigurationDb": "${SCADALINK_CONFIGURATIONDB_CONNECTION_STRING}"
"ConfigurationDb": "${SCADABRIDGE_CONFIGURATIONDB_CONNECTION_STRING}"
},
"Security": {
"LdapServer": "localhost",
@@ -29,8 +29,8 @@
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadabridge,dc=local",
"LdapServiceAccountDn": "cn=admin,dc=scadabridge,dc=local",
"LdapServiceAccountPassword": "${SCADALINK_LDAP_SERVICE_ACCOUNT_PASSWORD}",
"JwtSigningKey": "${SCADALINK_JWT_SIGNING_KEY}",
"LdapServiceAccountPassword": "${SCADABRIDGE_LDAP_SERVICE_ACCOUNT_PASSWORD}",
"JwtSigningKey": "${SCADABRIDGE_JWT_SIGNING_KEY}",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30
},
@@ -115,7 +115,7 @@ public sealed class BundleExporter : IBundleExporter
var templateManifest = _manifestBuilder.Build(
sourceEnvironment: sourceEnvironment,
exportedBy: user,
scadaLinkVersion: assemblyVersion,
scadaBridgeVersion: assemblyVersion,
encryption: encryptionSeed,
summary: summary,
contents: resolved.ContentManifest,
@@ -20,7 +20,7 @@ public sealed class ManifestBuilder
/// </summary>
/// <param name="sourceEnvironment">Environment label identifying where the bundle was exported from.</param>
/// <param name="exportedBy">Username of the operator who performed the export.</param>
/// <param name="scadaLinkVersion">ScadaBridge version string stamped in the manifest.</param>
/// <param name="scadaBridgeVersion">ScadaBridge version string stamped in the manifest.</param>
/// <param name="encryption">Encryption metadata when the content is encrypted; null for plain bundles.</param>
/// <param name="summary">High-level summary of artifact counts.</param>
/// <param name="contents">Per-entry content table describing each artifact in the bundle.</param>
@@ -29,7 +29,7 @@ public sealed class ManifestBuilder
public BundleManifest Build(
string sourceEnvironment,
string exportedBy,
string scadaLinkVersion,
string scadaBridgeVersion,
EncryptionMetadata? encryption,
BundleSummary summary,
IReadOnlyList<ManifestContentEntry> contents,
@@ -37,7 +37,7 @@ public sealed class ManifestBuilder
{
ArgumentNullException.ThrowIfNull(sourceEnvironment);
ArgumentNullException.ThrowIfNull(exportedBy);
ArgumentNullException.ThrowIfNull(scadaLinkVersion);
ArgumentNullException.ThrowIfNull(scadaBridgeVersion);
ArgumentNullException.ThrowIfNull(summary);
ArgumentNullException.ThrowIfNull(contents);
ArgumentNullException.ThrowIfNull(contentBytes);
@@ -50,7 +50,7 @@ public sealed class ManifestBuilder
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: sourceEnvironment,
ExportedBy: exportedBy,
ScadaBridgeVersion: scadaLinkVersion,
ScadaBridgeVersion: scadaBridgeVersion,
ContentHash: contentHash,
Encryption: encryption,
Summary: summary,
@@ -8,13 +8,13 @@ public class CliConfigTests
[Fact]
public void Load_DefaultFormat_IsJson()
{
var origUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
var origFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
var origUrl = Environment.GetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL");
var origFormat = Environment.GetEnvironmentVariable("SCADABRIDGE_FORMAT");
try
{
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", null);
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", null);
var config = CliConfig.Load();
@@ -23,18 +23,18 @@ public class CliConfigTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", origUrl);
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", origFormat);
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", origUrl);
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", origFormat);
}
}
[Fact]
public void Load_ManagementUrl_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
var orig = Environment.GetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL");
try
{
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", "http://central:5000");
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", "http://central:5000");
var config = CliConfig.Load();
@@ -42,17 +42,17 @@ public class CliConfigTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", orig);
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", orig);
}
}
[Fact]
public void Load_Format_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
var orig = Environment.GetEnvironmentVariable("SCADABRIDGE_FORMAT");
try
{
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", "table");
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", "table");
var config = CliConfig.Load();
@@ -60,7 +60,7 @@ public class CliConfigTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", orig);
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", orig);
}
}
@@ -81,19 +81,19 @@ public class CliConfigTests
var origHome = Environment.GetEnvironmentVariable("HOME");
var origUserProfile = Environment.GetEnvironmentVariable("USERPROFILE");
var origUrl = Environment.GetEnvironmentVariable("SCADALINK_MANAGEMENT_URL");
var origFormat = Environment.GetEnvironmentVariable("SCADALINK_FORMAT");
var origUser = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var origPass = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
var origUrl = Environment.GetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL");
var origFormat = Environment.GetEnvironmentVariable("SCADABRIDGE_FORMAT");
var origUser = Environment.GetEnvironmentVariable("SCADABRIDGE_USERNAME");
var origPass = Environment.GetEnvironmentVariable("SCADABRIDGE_PASSWORD");
var origStderr = Console.Error;
try
{
Environment.SetEnvironmentVariable("HOME", tempHome);
Environment.SetEnvironmentVariable("USERPROFILE", tempHome);
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", null);
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", null);
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", null);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", null);
var stderrCapture = new StringWriter();
Console.SetError(stderrCapture);
@@ -112,10 +112,10 @@ public class CliConfigTests
Console.SetError(origStderr);
Environment.SetEnvironmentVariable("HOME", origHome);
Environment.SetEnvironmentVariable("USERPROFILE", origUserProfile);
Environment.SetEnvironmentVariable("SCADALINK_MANAGEMENT_URL", origUrl);
Environment.SetEnvironmentVariable("SCADALINK_FORMAT", origFormat);
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", origUser);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", origPass);
Environment.SetEnvironmentVariable("SCADABRIDGE_MANAGEMENT_URL", origUrl);
Environment.SetEnvironmentVariable("SCADABRIDGE_FORMAT", origFormat);
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", origUser);
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", origPass);
try { Directory.Delete(tempHome, recursive: true); } catch { /* best-effort cleanup */ }
}
}
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
/// <summary>
/// Regression tests for CLI-006 — credentials could only be supplied via the
/// <c>--password</c> command-line option, which leaks into process listings and
/// shell history. A <c>SCADALINK_PASSWORD</c> / <c>SCADALINK_USERNAME</c> environment
/// shell history. A <c>SCADABRIDGE_PASSWORD</c> / <c>SCADABRIDGE_USERNAME</c> environment
/// fallback gives CI/CD a safer alternative.
/// </summary>
[Collection("Environment")]
@@ -14,10 +14,10 @@ public class CredentialResolutionTests
[Fact]
public void Load_Password_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
var orig = Environment.GetEnvironmentVariable("SCADABRIDGE_PASSWORD");
try
{
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", "s3cret");
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", "s3cret");
var config = CliConfig.Load();
@@ -25,17 +25,17 @@ public class CredentialResolutionTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", orig);
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", orig);
}
}
[Fact]
public void Load_Username_FromEnvironment()
{
var orig = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var orig = Environment.GetEnvironmentVariable("SCADABRIDGE_USERNAME");
try
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", "ci-user");
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", "ci-user");
var config = CliConfig.Load();
@@ -43,19 +43,19 @@ public class CredentialResolutionTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", orig);
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", orig);
}
}
[Fact]
public void Load_NoCredentialEnvVars_LeavesCredentialsNull()
{
var origUser = Environment.GetEnvironmentVariable("SCADALINK_USERNAME");
var origPass = Environment.GetEnvironmentVariable("SCADALINK_PASSWORD");
var origUser = Environment.GetEnvironmentVariable("SCADABRIDGE_USERNAME");
var origPass = Environment.GetEnvironmentVariable("SCADABRIDGE_PASSWORD");
try
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", null);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", null);
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", null);
var config = CliConfig.Load();
@@ -64,8 +64,8 @@ public class CredentialResolutionTests
}
finally
{
Environment.SetEnvironmentVariable("SCADALINK_USERNAME", origUser);
Environment.SetEnvironmentVariable("SCADALINK_PASSWORD", origPass);
Environment.SetEnvironmentVariable("SCADABRIDGE_USERNAME", origUser);
Environment.SetEnvironmentVariable("SCADABRIDGE_PASSWORD", origPass);
}
}
}
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Audit;
/// Connection string mirrors the Docker cluster's <c>scadabridge_app</c> account
/// from <c>docker/central-node-a/appsettings.Central.json</c>, with the host
/// pointed at the host-exposed port (<c>localhost:1433</c>). The
/// <c>SCADALINK_PLAYWRIGHT_DB</c> env var lets CI override the connection
/// <c>SCADABRIDGE_PLAYWRIGHT_DB</c> env var lets CI override the connection
/// without recompiling.
/// </para>
/// </summary>
@@ -34,11 +34,11 @@ internal static class AuditDataSeeder
private const string DefaultConnectionString =
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
/// <summary>
/// Connection string for the running cluster's configuration DB. Resolved
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// from <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// dev defaults.
/// </summary>
public static string ConnectionString
@@ -30,7 +30,7 @@ public class AuditGridColumnTests
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
"or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
private readonly PlaywrightFixture _fixture;
@@ -64,7 +64,7 @@ public class AuditLogPageTests
{
throw new InvalidOperationException(
"AuditDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.");
"or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.");
}
var runId = Guid.NewGuid().ToString("N");
@@ -28,11 +28,11 @@ internal static class SiteCallDataSeeder
private const string DefaultConnectionString =
"Server=localhost,1433;Database=ScadaBridgeConfig;User Id=scadabridge_app;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=5";
private const string EnvVar = "SCADALINK_PLAYWRIGHT_DB";
private const string EnvVar = "SCADABRIDGE_PLAYWRIGHT_DB";
/// <summary>
/// Connection string for the running cluster's configuration DB. Resolved
/// from <c>SCADALINK_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// from <c>SCADABRIDGE_PLAYWRIGHT_DB</c> when set, otherwise the local docker
/// dev defaults.
/// </summary>
public static string ConnectionString
@@ -101,7 +101,7 @@ public class SiteCallsPageTests
/// <summary>Skip reason shared by the DB-seeding tests when MSSQL is down.</summary>
private const string DbUnavailableSkipReason =
"SiteCallDataSeeder cannot reach MSSQL at localhost:1433 — bring up infra/docker-compose and docker/deploy.sh, " +
"or set SCADALINK_PLAYWRIGHT_DB to a reachable connection string.";
"or set SCADABRIDGE_PLAYWRIGHT_DB to a reachable connection string.";
[SkippableFact]
public async Task FilterNarrowing_ChannelFilterShrinksGrid()
@@ -4,7 +4,7 @@ namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests;
public class DesignTimeDbContextFactoryTests : IDisposable
{
private const string EnvVar = "SCADALINK_DESIGNTIME_CONNECTIONSTRING";
private const string EnvVar = "SCADABRIDGE_DESIGNTIME_CONNECTIONSTRING";
private readonly string? _originalEnv;
public DesignTimeDbContextFactoryTests()
@@ -33,7 +33,7 @@ public sealed class MsSqlMigrationFixture : IDisposable
private const string DefaultAdminConnectionString =
"Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";
private const string AdminEnvVar = "SCADALINK_MSSQL_TEST_CONN";
private const string AdminEnvVar = "SCADABRIDGE_MSSQL_TEST_CONN";
public string DatabaseName { get; }
@@ -0,0 +1,46 @@
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Host;
using ZB.MOM.WW.ScadaBridge.Host.Actors;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Verifies the DI bridge that exposes the Akka <see cref="ActorSystem"/> — owned by
/// <see cref="AkkaHostedService"/>, not registered as a DI singleton — to consumers that
/// resolve <c>ActorSystem</c> from the container (notably the shared ZB.MOM.WW.Health Akka
/// checks). The bridge is registered TRANSIENT so each resolve re-reads the current value:
/// null while the hosted service is warming up (checks treat that as Degraded), the live
/// system afterwards. A SINGLETON would cache the startup-time null forever.
/// </summary>
public sealed class ActorSystemBridgeTests
{
[Fact]
public void ActorSystem_ResolvesNull_BeforeHostedServiceStarts()
{
var services = new ServiceCollection();
// Register AkkaHostedService the same way Program.cs does, supplying the minimal
// constructor dependencies so the container can build it. Its ActorSystem property
// is null until StartAsync runs — which it never does here.
services.AddSingleton(Options.Create(new NodeOptions()));
services.AddSingleton(Options.Create(new ClusterOptions()));
services.AddSingleton(Options.Create(new CommunicationOptions()));
services.AddSingleton<ILogger<AkkaHostedService>>(NullLogger<AkkaHostedService>.Instance);
services.AddSingleton<AkkaHostedService>();
// The bridge under test: TRANSIENT factory that re-reads the owned ActorSystem.
services.AddTransient<ActorSystem>(sp =>
sp.GetRequiredService<AkkaHostedService>().ActorSystem!);
using var provider = services.BuildServiceProvider();
// The hosted service has not started, so the bridge must yield null (not throw).
Assert.Null(provider.GetService<ActorSystem>());
}
}
@@ -1,11 +1,20 @@
using System.Linq;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.ScadaBridge.Host.Health;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Health;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// WP-12: Tests for /health/ready and /health/active endpoints.
/// WP-12: Tests for the three-tier health endpoints after adopting the shared
/// ZB.MOM.WW.Health probes. Verifies that /health/ready, /health/active and the new
/// /healthz tier are mapped, and that the readiness/active tier split is now carried by
/// the canonical <see cref="ZbHealthTags"/> (Ready for database + akka-cluster, Active for
/// active-node) rather than by check-name predicates. These are pure route/tag assertions
/// — they require no database, LDAP, or formed Akka cluster.
/// </summary>
public class HealthCheckTests : IDisposable
{
@@ -25,41 +34,49 @@ public class HealthCheckTests : IDisposable
}
}
private WebApplicationFactory<Program> CreateCentralFactory()
{
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
return factory;
}
private static IEnumerable<HealthCheckRegistration> Registrations(WebApplicationFactory<Program> factory) =>
factory.Services.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations;
[Fact]
public async Task HealthReady_Endpoint_ReturnsResponse()
public async Task HealthReady_Endpoint_IsMapped()
{
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var factory = CreateCentralFactory();
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/ready");
// The endpoint exists and returns a status code.
// With test infrastructure (no real DB), the database check may fail,
// so we accept either 200 (Healthy) or 503 (Unhealthy).
// The endpoint exists and returns a status code. With test infrastructure
// (no real DB / cluster) the readiness checks may report Unhealthy, so we
// accept either 200 (Healthy/Degraded) or 503 (Unhealthy) — never 404.
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode);
Assert.True(
response.StatusCode == System.Net.HttpStatusCode.OK ||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
@@ -72,39 +89,19 @@ public class HealthCheckTests : IDisposable
}
[Fact]
public async Task HealthActive_Endpoint_ReturnsResponse()
public async Task HealthActive_Endpoint_IsMapped()
{
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var factory = CreateCentralFactory();
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/active");
// In test mode, the ActorSystem may not be fully available,
// so the active-node check returns 503 (Unhealthy).
Assert.NotEqual(System.Net.HttpStatusCode.NotFound, response.StatusCode);
Assert.True(
response.StatusCode == System.Net.HttpStatusCode.OK ||
response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable,
@@ -117,46 +114,21 @@ public class HealthCheckTests : IDisposable
}
[Fact]
public async Task HealthReady_Endpoint_ExcludesActiveNodeCheck()
public async Task Healthz_LivenessEndpoint_IsMappedAndReturns200()
{
// Host-001 regression: /health/ready must reflect cluster membership + DB
// connectivity only (REQ-HOST-4a), NOT cluster leadership. The leader-only
// "active-node" check belongs solely to /health/active. If /health/ready
// included "active-node", a fully operational standby central node would
// permanently report 503, breaking load-balancer failover readiness.
// New tier added by adopting the shared library: /healthz runs no checks, so it
// returns 200 as long as the process is up — independent of DB / cluster state.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var factory = CreateCentralFactory();
var client = factory.CreateClient();
_disposables.Add(client);
var response = await client.GetAsync("/health/ready");
var body = await response.Content.ReadAsStringAsync();
var response = await client.GetAsync("/healthz");
// The readiness body lists each executed check by name in its entries map.
// The leader-only "active-node" check must not be among them.
Assert.DoesNotContain("active-node", body);
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
finally
{
@@ -165,43 +137,54 @@ public class HealthCheckTests : IDisposable
}
[Fact]
public async Task ActiveNodeHealthCheck_SystemNotStarted_ReturnsUnhealthy()
public void ReadyTier_Carries_Database_And_AkkaCluster()
{
// AkkaHostedService before StartAsync has ActorSystem == null.
// The integration test (HealthActive_Endpoint_ReturnsResponse) validates the full
// endpoint wiring. This test validates the null-system path via WebApplicationFactory
// where the ActorSystem may not be available.
// Host-001 regression guard: readiness reflects cluster membership + DB connectivity
// only (REQ-HOST-4a), NOT cluster leadership. The split is now carried by the Ready tag
// rather than a check-name predicate: database + akka-cluster are Ready-tagged, and the
// leader-only active-node check is NOT — so a fully operational standby central node
// still reports ready on /health/ready.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Database:SkipMigrations"] = "true",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
});
_disposables.Add(factory);
var factory = CreateCentralFactory();
var client = factory.CreateClient();
_disposables.Add(client);
var registrations = Registrations(factory).ToDictionary(r => r.Name);
var response = await client.GetAsync("/health/active");
var body = await response.Content.ReadAsStringAsync();
Assert.True(registrations.ContainsKey("database"), "Expected a 'database' health check.");
Assert.True(registrations.ContainsKey("akka-cluster"), "Expected an 'akka-cluster' health check.");
// Active-node check returns 503 when ActorSystem is not yet available or not leader
Assert.Equal(System.Net.HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("active-node", body);
Assert.Contains(ZbHealthTags.Ready, registrations["database"].Tags);
Assert.Contains(ZbHealthTags.Ready, registrations["akka-cluster"].Tags);
// The leader-only active-node check must NOT be on the readiness tier.
Assert.DoesNotContain(ZbHealthTags.Ready, registrations["active-node"].Tags);
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", previousEnv);
}
}
[Fact]
public void ActiveTier_Carries_Only_ActiveNode()
{
// The active-node leader check carries the Active tag (→ /health/active); the readiness
// checks do not, so /health/active reports leadership alone.
var previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
var factory = CreateCentralFactory();
var registrations = Registrations(factory).ToDictionary(r => r.Name);
Assert.True(registrations.ContainsKey("active-node"), "Expected an 'active-node' health check.");
Assert.Contains(ZbHealthTags.Active, registrations["active-node"].Tags);
Assert.DoesNotContain(ZbHealthTags.Active, registrations["database"].Tags);
Assert.DoesNotContain(ZbHealthTags.Active, registrations["akka-cluster"].Tags);
}
finally
{
@@ -110,10 +110,10 @@ public class HotPathLatencyTests
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
// Default budget 50 µs (spec target). Override via env for slow CI:
// SCADALINK_AUDIT_FILTER_4KB_P95_US — interpret as the regression
// SCADABRIDGE_AUDIT_FILTER_4KB_P95_US — interpret as the regression
// guard threshold. Print the observed value so a missed budget gives
// useful telemetry on the test output.
var threshold = GetThresholdMicroseconds("SCADALINK_AUDIT_FILTER_4KB_P95_US", 50d);
var threshold = GetThresholdMicroseconds("SCADABRIDGE_AUDIT_FILTER_4KB_P95_US", 50d);
Assert.True(p95Us < threshold,
$"4KB body filter p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs");
}
@@ -137,7 +137,7 @@ public class HotPathLatencyTests
var p95Us = MeasureP95Microseconds(MeasureIterations, () => _ = filter.Apply(evt));
var threshold = GetThresholdMicroseconds("SCADALINK_AUDIT_FILTER_RAW_P95_US", 10d);
var threshold = GetThresholdMicroseconds("SCADABRIDGE_AUDIT_FILTER_RAW_P95_US", 10d);
Assert.True(p95Us < threshold,
$"Raw-event filter p95 = {p95Us:F1} µs; threshold = {threshold:F1} µs");
}
@@ -130,7 +130,7 @@ public sealed class BundleImporterLoadTests
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
@@ -152,7 +152,7 @@ public sealed class BundleImporterLoadTests
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
scadaBridgeVersion: "1.0.0",
encryption: seed,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
@@ -471,7 +471,7 @@ public sealed class BundleImporterLoadTests
var manifest = rig.ManifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
@@ -28,7 +28,7 @@ public sealed class BundleSerializerTests
new ManifestBuilder().Build(
sourceEnvironment: "test-env",
exportedBy: "tester",
scadaLinkVersion: "1.0.0",
scadaBridgeVersion: "1.0.0",
encryption: encryption,
summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# One-time scrub of residual ScadaLink/scadalink references → ScadaBridge.
# Operates on git-tracked TEXT files only (git grep -I skips binaries),
# minus carve-out migration records whose before→after meaning must survive.
# Substitutions are applied most-specific-first so a broad rule cannot
# double-replace an earlier result. Idempotent: re-running is a no-op.
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
# NOTE: this script only sees git-TRACKED files (via git grep). Git-ignored
# trees — notably /deploy/ (see .gitignore) — are out of its reach and were
# scrubbed separately, by hand, with the same substitution set. A git-based
# completeness gate likewise cannot see them; verify those trees with a plain
# `grep -rniI scadalink <dir>`.
# Carve-outs (migration records): prior rename tooling/design, the DB-rename
# helper, this script itself, and the rename design + plan docs (which document
# the old→new mapping and would be self-corrupted by the substitution).
EXCLUDES_RE='^(tools/rename-to-scadabridge\.sh|tools/scrub-scadalink-refs\.sh|docker/rename-databases\.sh|docs/plans/2026-05-28-scadabridge-rename-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-design\.md|docs/plans/2026-05-31-folder-repo-rename-scadabridge-plan\.md)$'
files=()
while IFS= read -r f; do
[[ "$f" =~ $EXCLUDES_RE ]] && continue
files+=("$f")
done < <(git grep -liI 'scadalink' -- .)
if [[ ${#files[@]} -eq 0 ]]; then
echo "No files to scrub."
exit 0
fi
printf 'Scrubbing %d file(s):\n' "${#files[@]}"
printf ' %s\n' "${files[@]}"
sed -i '' \
-e 's/ScadaLink\.Host\.exe/ZB.MOM.WW.ScadaBridge.Host.exe/g' \
-e 's/ScadaLink__/ScadaBridge__/g' \
-e 's/SCADALINK_/SCADABRIDGE_/g' \
-e 's/scadaLinkVersion/scadaBridgeVersion/g' \
-e 's/scadalink_app/scadabridge_app/g' \
-e 's/ScadaLink/ScadaBridge/g' \
-e 's/scadalink/scadabridge/g' \
"${files[@]}"
echo "Done."