Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adf1bd2693 | |||
| bbff1d19b5 | |||
| 2a7ff03718 | |||
| 38e48299a4 | |||
| 43228185b4 | |||
| e3ca5ac0cf | |||
| 4c5e7eb917 | |||
| bdee12f4e9 | |||
| 3763f6d2d8 | |||
| 300841b205 | |||
| 3797af7f0f | |||
| a47317d010 | |||
| c899cb162c | |||
| d69031dd08 | |||
| d317c07ea5 | |||
| e01f3bdabe |
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
```
|
||||

|
||||
<!-- 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 (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 (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 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 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&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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -->|"< FailoverRetryCount"| SAME
|
||||
SAME -.->|"retry"| RT
|
||||
BR -->|">= FailoverRetryCount AND backup exists"| FO
|
||||
BR -->|">= 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) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||

|
||||
<!-- 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.
|
||||
|
||||
|
||||
@@ -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 T0–T9 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 T0–T9 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (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 (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 (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 (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 |
@@ -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
@@ -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<SiteStreamEvent><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 < max)"| RT
|
||||
RT --> R
|
||||
D -->|"failure (retry >= 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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, "Update", "Template",<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
|
||||
|
||||
@@ -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 Central–Site 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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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 Central–Site 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`).
|
||||
|
||||
@@ -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/> (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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<!--
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+1
-1
@@ -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>(),
|
||||
|
||||
Executable
+45
@@ -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."
|
||||
Reference in New Issue
Block a user