Compare commits
431 Commits
f47d4e1030
...
low/T5
| Author | SHA1 | Date | |
|---|---|---|---|
| 41bdc178c2 | |||
| e9dc13689a | |||
| f62b017cc7 | |||
| dbcbc5bd41 | |||
| 1145df682e | |||
| 3e5a5fde81 | |||
| b62981ff80 | |||
| 31a7c8a88a | |||
| 7757b9c6fc | |||
| 0038d46c9a | |||
| cea3930b94 | |||
| f627cee9c0 | |||
| 14cb283172 | |||
| 522cfbfda6 | |||
| 1ce0c5955f | |||
| 9377fa1678 | |||
| 92246c9734 | |||
| 1fee9755d7 | |||
| 205bddb3b8 | |||
| 206c588608 | |||
| bd929d98fc | |||
| 990f4c018d | |||
| 5da4ab3ebd | |||
| 3d2f3f59b2 | |||
| 58c2e14c38 | |||
| 58f605609d | |||
| f42aa14033 | |||
| 818775b311 | |||
| 4e01cbc2c3 | |||
| 1f8ab8e3b3 | |||
| 846b77f5b4 | |||
| da276ff15a | |||
| 81e0005628 | |||
| b850c3a47f | |||
| c5e894d9b3 | |||
| 74066bbe27 | |||
| 6084465e23 | |||
| d6fe3ae62a | |||
| 90b7cc6c48 | |||
| 9b6afe3efd | |||
| cbfe296279 | |||
| 105c230cb1 | |||
| 944595ad32 | |||
| f088cb81ba | |||
| 003783a593 | |||
| 4f97e72ac9 | |||
| c582508ae5 | |||
| 8f31184b7d | |||
| 8e99b3b4c0 | |||
| 7926e8bb46 | |||
| 76098ffcd4 | |||
| 1cc4ccd258 | |||
| 20e16c934c | |||
| ec4ad984c1 | |||
| 094307e18b | |||
| baaf0a183c | |||
| dc6410dfad | |||
| 3777e50564 | |||
| 40e07aa08e | |||
| 9dd288b3ad | |||
| f313c5e62e | |||
| f9d4e2f514 | |||
| d603a2f66e | |||
| 49fbbcfaeb | |||
| e7a126a9fc | |||
| 5fc75d93fd | |||
| 3b7dbfa082 | |||
| d131416673 | |||
| f0f607ea46 | |||
| 2a38b6ac76 | |||
| 75648ea28b | |||
| 84af475683 | |||
| 3ce9cc3657 | |||
| 1c675e9153 | |||
| 92ff6a0d8c | |||
| 174ad952dc | |||
| 0a8fe68d0a | |||
| 8997a46b74 | |||
| 5fbf53601c | |||
| 0c1702acbc | |||
| 2dc919e8a4 | |||
| 95865f0e9b | |||
| 2f96709341 | |||
| 46523db2bd | |||
| 31ca9407a6 | |||
| e62b4a5baf | |||
| 7a2980e2f1 | |||
| 0d4978953f | |||
| 7df05ccc27 | |||
| 294eb163cd | |||
| aada53b27a | |||
| 5198d3dc7f | |||
| c86cead5c2 | |||
| 321ab22111 | |||
| 5f81e65684 | |||
| 3e3748b985 | |||
| 55e0178d5c | |||
| 1c31e6bcb8 | |||
| 353af72265 | |||
| c5ea3a7b59 | |||
| 7fc77f2790 | |||
| cc95590196 | |||
| cf6d19d09d | |||
| 5fd7bf524a | |||
| ad7df925ae | |||
| 591307c13e | |||
| 537a4f524d | |||
| 1a7bff6a19 | |||
| c8c4c2bcde | |||
| 35f371ed74 | |||
| 63cf4c6f0c | |||
| 0e416cac0a | |||
| 7ed2c29842 | |||
| 2b45a59c5d | |||
| d30f54639f | |||
| 0bbe42eaa1 | |||
| 6d222ea491 | |||
| 7a6f997180 | |||
| 555d1b4afc | |||
| 883601a72b | |||
| 6b12a7b75e | |||
| 0121f55f1e | |||
| c07659c0e0 | |||
| ded7cf0d6d | |||
| f7f4554a36 | |||
| 850e23646a | |||
| ff073450dc | |||
| e96b5d49f9 | |||
| b0b57f8d54 | |||
| eedbf13712 | |||
| 035c5feeb8 | |||
| b02d7a92bb | |||
| 75f267c4c9 | |||
| 7dcd93c326 | |||
| 591eaac881 | |||
| 6ab50fed1f | |||
| a58d79b793 | |||
| bf75e265ab | |||
| 8698d1804b | |||
| 344bdc4da5 | |||
| 6a4fa220de | |||
| a61e041e58 | |||
| c81a6615ba | |||
| 280e57896b | |||
| af1e0a86b2 | |||
| c22b06deba | |||
| d95292c95d | |||
| 41013dcab3 | |||
| 92997b40dc | |||
| 5fe7135e2c | |||
| 61fc88c8a8 | |||
| d0110cf406 | |||
| 80668a07bd | |||
| 6721c8a224 | |||
| 3dbd6cd57c | |||
| 92015da4e0 | |||
| 1911de2c3c | |||
| b5e7bc8b3c | |||
| 4d93d2580b | |||
| 6dd7858619 | |||
| ff58ad4f60 | |||
| dbfd5e7a7e | |||
| 5919e29969 | |||
| ede5275d3a | |||
| 4f33eccfa7 | |||
| 7c8dd654ba | |||
| 3b7489a7b0 | |||
| 45d08929e7 | |||
| cad3bcb6bf | |||
| c42348eff5 | |||
| 5836c79d3d | |||
| 10d076a9da | |||
| 12e6f0d2c5 | |||
| baea6cc2e0 | |||
| 0139c90f89 | |||
| 87ffe1b774 | |||
| 980b4a37f6 | |||
| 6dbb372adf | |||
| 1f7d26d4d9 | |||
| 7e67bd61c7 | |||
| 1b65e820fb | |||
| 04018aab6e | |||
| 4549b93d2a | |||
| e8b34be255 | |||
| d1ec0fe5da | |||
| 000d283c99 | |||
| 08c4ce5769 | |||
| e5a4d0276a | |||
| 6ad710bdbd | |||
| 779f8a8d78 | |||
| fe3ea684f5 | |||
| 138e2c1006 | |||
| 4576f73c4c | |||
| e60f2d5dad | |||
| 21f6a0df92 | |||
| e49496c856 | |||
| 6255409f16 | |||
| f0f2b23d03 | |||
| 38d33cf4b7 | |||
| 9bc1e5852e | |||
| 83af2b2eaf | |||
| b8c6c7d432 | |||
| b9a57fe06e | |||
| 1b013a56a7 | |||
| fe9d8865fe | |||
| 7072dc25a2 | |||
| a53671e0af | |||
| fb5c41bd73 | |||
| cc7d0dbf18 | |||
| 7e7fbe9bf0 | |||
| 62ab8f68d1 | |||
| 0b23da429e | |||
| 03ff5cd8bc | |||
| fbb788d463 | |||
| 8d9bcedaf3 | |||
| b077ca1b7a | |||
| 51bef634d0 | |||
| a1df7ef26c | |||
| 57d2d193fc | |||
| eb445ea7e7 | |||
| 62a96e5f22 | |||
| 6e8d346670 | |||
| ccbdd4bcd1 | |||
| 15ef1f32a8 | |||
| dd0a846b64 | |||
| e46060fada | |||
| 5e9cbd3ecf | |||
| 3dd7aa40ff | |||
| 9cb6831380 | |||
| de65ca2b5d | |||
| ac6bcde159 | |||
| b2345c32d4 | |||
| c5352f3ed8 | |||
| 154d584190 | |||
| 801b042208 | |||
| eb40cd8f29 | |||
| be4ca76f5f | |||
| 8befebd476 | |||
| 130e9c918f | |||
| e92cdc6d3a | |||
| a64fc26391 | |||
| 183901e8ac | |||
| 92ccbac8db | |||
| 925623b878 | |||
| b223507070 | |||
| d7cbdce65c | |||
| 3cf8cabaf9 | |||
| 4f9982c53f | |||
| 529585163c | |||
| c061ba733f | |||
| 3d72d2ee4b | |||
| df2518611f | |||
| f4063f811f | |||
| 8b921e3106 | |||
| 12b8839f33 | |||
| 827bf44684 | |||
| 83f384247c | |||
| fb8f751fac | |||
| cf58077398 | |||
| 61e5564c26 | |||
| 9cef3c5d45 | |||
| bf4c5dccbd | |||
| d82d3451e7 | |||
| b009507e10 | |||
| 1d05ecdf1e | |||
| c6b782e635 | |||
| 5eea386ebf | |||
| fbb79ef83a | |||
| d95328efb3 | |||
| 6be5f746d5 | |||
| e347286f28 | |||
| de77bc30e3 | |||
| 9bd3c76d96 | |||
| 76124590f0 | |||
| 2c64683303 | |||
| e23cd317b0 | |||
| e20812cbae | |||
| 294f53b49a | |||
| 0ab276dac0 | |||
| 21556cc1a7 | |||
| d7f8ca455b | |||
| 7f1db1b214 | |||
| 8c4175d8ce | |||
| 0bb553212a | |||
| 79ebd14baa | |||
| 10455ec53b | |||
| e99a8731e9 | |||
| 1c13b4af99 | |||
| 01911508b9 | |||
| b784c7117f | |||
| 16b32257f5 | |||
| d78b533045 | |||
| 824facab39 | |||
| 7ab1c86178 | |||
| 9df4f9bb84 | |||
| a038fc0899 | |||
| e51d488845 | |||
| 3ac8265775 | |||
| 4fbf3ccddc | |||
| faf8ecb54e | |||
| baacbb5422 | |||
| b04cb14bde | |||
| 16ca998aa4 | |||
| 848d75c94c | |||
| 378880f0ed | |||
| 497c0aac52 | |||
| 9e996a4d50 | |||
| b3122bfefb | |||
| 6966ad4b3f | |||
| e366732318 | |||
| 6990dfdf2d | |||
| d4138f54d2 | |||
| 0d676354bb | |||
| a19f019bc3 | |||
| 897fbee9d4 | |||
| 54293b9691 | |||
| 4ea4e1b2b8 | |||
| 5e2f49b79e | |||
| 73f6931730 | |||
| cc57c857b8 | |||
| 6143b7e4f3 | |||
| ac71caca84 | |||
| be993d4d54 | |||
| 480f7c7a49 | |||
| 2e4df81ba9 | |||
| 94218c936a | |||
| da09be3127 | |||
| 624cc5a408 | |||
| 38cf17917a | |||
| 71cec3dcff | |||
| 99c153ac23 | |||
| 5f743d05d6 | |||
| b80abbb14b | |||
| 6c2d16d4af | |||
| a08ddab9dd | |||
| 744eb090ac | |||
| 94512acf1f | |||
| 2c6c764d3c | |||
| a30f8551e9 | |||
| afd0287f54 | |||
| 1041f87b59 | |||
| 5572edda85 | |||
| aff7264df8 | |||
| 510b0010d6 | |||
| 42ad31aded | |||
| e3c0503a4f | |||
| a0527f9b5a | |||
| 5f7d7e1b58 | |||
| 78418346df | |||
| 4920b89666 | |||
| 989db9317d | |||
| 81bf7322f0 | |||
| 8033a7f12d | |||
| 63cddfb65b | |||
| 965f5006f2 | |||
| 294da8b2db | |||
| bbb7942788 | |||
| d5b134b117 | |||
| eb8b44c29d | |||
| a6fa36043a | |||
| 05a4a547f4 | |||
| 4d57e34ff3 | |||
| b3d8990a0f | |||
| 5655b75fe6 | |||
| dce6f83488 | |||
| fd34e25cb1 | |||
| eb26bf3248 | |||
| e5a609be83 | |||
| f1efe6e081 | |||
| 0e41e7c2e4 | |||
| 5f97c9d1ed | |||
| 9d373efbe0 | |||
| 4c0f1eaaf7 | |||
| 0f2b2b8351 | |||
| 5be0cec601 | |||
| 106fb8b149 | |||
| b0fe7b15ca | |||
| 3070169e5d | |||
| ea4116cc5b | |||
| ca21615090 | |||
| a474eb6bd6 | |||
| 9e4dedc987 | |||
| 6aa2ee8095 | |||
| e2749b7d69 | |||
| edd49765d6 | |||
| 7e11f9aac8 | |||
| e6e9dbfedb | |||
| 6d262f7d7c | |||
| 4b90ebb588 | |||
| 4de61d29f5 | |||
| 1ec057a32a | |||
| a591a9fb47 | |||
| e9100d0b74 | |||
| 672ac5ff04 | |||
| f073241f52 | |||
| 98e957903f | |||
| ca2a9ac507 | |||
| abe06a2163 | |||
| 95681ac0b2 | |||
| d73762bf76 | |||
| 02a84b074a | |||
| 9b5535ea47 | |||
| 406ede19dd | |||
| ba7b38a654 | |||
| e69e9c635b | |||
| a4f9968917 | |||
| 290e85cb38 | |||
| 468959ca8a | |||
| 30c60f9d5f | |||
| d30cdea487 | |||
| f2b73367d5 | |||
| da669bfc9b | |||
| 2d50d5dcf0 | |||
| aecc106657 | |||
| 0586e64f64 | |||
| 37c03e5fc2 | |||
| bea08f9673 | |||
| 32fd953969 | |||
| c715565bd2 | |||
| f98fa84e4a | |||
| 6ec1ea7d65 | |||
| c3ab37523a | |||
| 2f124fa02c | |||
| 6c2a43a238 | |||
| dee55aadc6 | |||
| 30425726d4 | |||
| 3729ff2152 | |||
| 19f7ea5eeb | |||
| 1e91784ba3 | |||
| 5a965639f9 | |||
| f72403d6f0 |
@@ -6,3 +6,9 @@ bin/
|
||||
obj/
|
||||
[Aa]rtifacts/
|
||||
*.user
|
||||
|
||||
# Claude Code tooling / agent worktrees (local-only)
|
||||
.claude/
|
||||
|
||||
# Local ops/infra notes — internal access details, never publish
|
||||
zimmer-vpn-tunnel.md
|
||||
|
||||
@@ -15,12 +15,18 @@ it produces.
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| [`CLAUDE.md`](CLAUDE.md) | High-level index of the sister projects + working guidance |
|
||||
| [`components/`](components/) | Component-normalization framework (per concern: target spec, current state, gaps) |
|
||||
| [`components/auth/`](components/auth/) | First normalized component — login / identity / authorization |
|
||||
| [`components/ui-theme/`](components/ui-theme/) | Second normalized component — UI theme / design tokens / layout |
|
||||
| [`docs/plans/`](docs/plans/) | Implementation plans (e.g. the ZB.MOM.WW.Auth and ZB.MOM.WW.Theme builds) |
|
||||
| [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | **Built shared library** (.NET 10) — the realized output of the auth normalization |
|
||||
| [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | **Built shared RCL** (.NET 10) — the realized output of the UI-theme normalization |
|
||||
| [`components/`](components/) | Component-normalization framework (per concern: target spec, current state, gaps) — 7 components |
|
||||
| [`docs/plans/`](docs/plans/) | Implementation + design plans (incl. Galaxy Repository and LocalDb, which have no `components/` entry) |
|
||||
| [`infra/glauth/`](infra/glauth/) | The shared GLAuth every app's dev/test LDAP points at (`10.100.0.35:3893`) |
|
||||
| [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | **Built shared library** (.NET 10) — login / identity / API keys |
|
||||
| [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | **Built shared RCL** (.NET 10) — Technical-Light design system |
|
||||
| [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) | **Built shared library** — canonical audit event + writer seam |
|
||||
| [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) | **Built shared library** — readiness / liveness / active-node probes |
|
||||
| [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) | **Built shared library** — OTel metrics / traces + Serilog |
|
||||
| [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) | **Built shared library** — options validation + startup preflight |
|
||||
| [`ZB.MOM.WW.Secrets/`](ZB.MOM.WW.Secrets/) | **Built shared library** — AES-256-GCM secret store + `${secret:}` resolution + CLI + SQL-Server / Akka cluster replication |
|
||||
| [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) | **Built shared library** — Galaxy object-hierarchy SQL browse + gRPC service |
|
||||
| [`ZB.MOM.WW.LocalDb/`](ZB.MOM.WW.LocalDb/) | **Built shared library** — embedded SQLite cache + 2-node gRPC replication |
|
||||
|
||||
## The sister projects
|
||||
|
||||
@@ -33,6 +39,8 @@ not by a shared build. scadaproj *indexes* them; it does not contain them.
|
||||
MXAccess parity without loading 32-bit COM. The linchpin both others depend on.
|
||||
- **ScadaBridge** — distributed SCADA platform on Akka.NET (hub-and-spoke: one central cluster
|
||||
+ N site clusters); its Data Connection Layer ingests OPC UA and MxAccess.
|
||||
- **HistorianGateway** (`historiangw`) — gRPC sidecar over the AVEVA Historian (read/write) plus
|
||||
read-only Galaxy browse; no COM, no x86 worker. OtOpcUa's sole historian backend.
|
||||
|
||||
Data flow:
|
||||
|
||||
@@ -51,17 +59,27 @@ The sister repos kept re-implementing the same cross-cutting concerns and drifti
|
||||
**code-verified current state**, and a **gaps / adoption backlog**. Convention + workflow in
|
||||
[`components/README.md`](components/README.md).
|
||||
|
||||
| Component | Status | Folder |
|
||||
|---|---|---|
|
||||
| Auth (login / identity / authz) | Built (library 0.1.0); apps not yet adopted | [`components/auth/`](components/auth/) |
|
||||
| UI Theme (layout / tokens / components) | Built (RCL 0.1.0); apps not yet adopted | [`components/ui-theme/`](components/ui-theme/) |
|
||||
Status verified 2026-07-18 against the feed + consumer branches (see [Roadmap](#roadmap)).
|
||||
"All four" = OtOpcUa, MxAccessGateway, ScadaBridge, HistorianGateway.
|
||||
|
||||
| Component | Feed | Adoption | Folder |
|
||||
|---|---|---|---|
|
||||
| Auth (login / identity / authz) | `0.1.0`–`0.1.5` | mxaccessgw / ScadaBridge / HistorianGateway @ `0.1.5`; OtOpcUa @ `0.1.1` | [`components/auth/`](components/auth/) |
|
||||
| UI Theme (layout / tokens / components) | `0.2.0`–`0.3.1` | all four @ `0.3.1` | [`components/ui-theme/`](components/ui-theme/) |
|
||||
| Audit (event model + writer seam) | `0.1.0` | all four (DEEP) | [`components/audit/`](components/audit/) |
|
||||
| Config + validation | `0.1.0` | all four | [`components/configuration/`](components/configuration/) |
|
||||
| Observability (metrics / traces / logs) | `0.1.0` | all four | [`components/observability/`](components/observability/) |
|
||||
| Health (readiness / liveness / active-node) | `0.1.0` | all four | [`components/health/`](components/health/) |
|
||||
| Secrets (encrypted store + `${secret:}`) | `0.1.0`–`0.2.0` | all four @ `0.1.2` (`0.1.3`/`0.2.0` published, not yet consumed) | [`components/secrets/`](components/secrets/) |
|
||||
| Galaxy Repository (SQL browse + gRPC) | `0.1.0`, `0.2.0` | HistorianGateway + mxaccessgw | _(design in `docs/plans/`)_ |
|
||||
| LocalDb (embedded cache + 2-node sync) | `0.1.0` | none yet | _(design in `docs/plans/`)_ |
|
||||
|
||||
## `ZB.MOM.WW.Theme` — the shared UI kit
|
||||
|
||||
The UI-theme component, realized as a single-package .NET 10 Razor Class Library the three
|
||||
apps can adopt to stop copy-pasting the Technical-Light design system. **Built and tested at
|
||||
0.1.0; adoption by the apps is the follow-on** (tracked in
|
||||
[`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md)).
|
||||
The UI-theme component, realized as a single-package .NET 10 Razor Class Library that replaced
|
||||
each app's copy-pasted Technical-Light design system. **Adopted by all four apps, currently on
|
||||
`0.3.1`** (feed carries `0.2.0`–`0.3.1`); adoption history in
|
||||
[`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
||||
|
||||
| Asset / Component | Purpose | Used by |
|
||||
|---|---|---|
|
||||
@@ -74,16 +92,16 @@ apps can adopt to stop copy-pasting the Technical-Light design system. **Built a
|
||||
| `LoginCard` | Static form-POST sign-in card | OtOpcUa, ScadaBridge (MxGateway when login page added) |
|
||||
| `TechButton`, `TechCard`, `TechField` | Common controls (Bootstrap 5 wrappers) | all |
|
||||
|
||||
**Consumer matrix:** all three apps consume the single `ZB.MOM.WW.Theme` package —
|
||||
OtOpcUa `AdminUI`, MxAccessGateway `Server`, ScadaBridge `Host` + `CentralUI`.
|
||||
**Consumer matrix:** all four apps consume the single `ZB.MOM.WW.Theme` package —
|
||||
OtOpcUa `AdminUI`, MxAccessGateway `Server`, ScadaBridge `Host` + `CentralUI`, HistorianGateway `Server`.
|
||||
|
||||
### Build & test
|
||||
|
||||
```bash
|
||||
cd ZB.MOM.WW.Theme
|
||||
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
|
||||
dotnet test # 32 bUnit tests
|
||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
|
||||
dotnet test # 48 bUnit tests (verified 2026-07-18)
|
||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.<version>.nupkg (feed is at 0.3.1)
|
||||
```
|
||||
|
||||
Stack: .NET 10 · Razor Class Library · bUnit · xUnit · central package management.
|
||||
@@ -93,9 +111,10 @@ More detail: [`ZB.MOM.WW.Theme/README.md`](ZB.MOM.WW.Theme/README.md).
|
||||
|
||||
## `ZB.MOM.WW.Auth` — the shared library
|
||||
|
||||
The auth component, realized as a four-package .NET 10 library the three apps can adopt to stop
|
||||
re-implementing authentication. **Built and tested at 0.1.0; adoption by the apps is the
|
||||
follow-on** (tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md)).
|
||||
The auth component, realized as a four-package .NET 10 library that replaced each app's
|
||||
re-implemented authentication. **Adopted by all four apps** — pins drift deliberately
|
||||
(OtOpcUa `0.1.1`, ScadaBridge `0.1.3`, MxAccessGateway + HistorianGateway `0.1.4`); history in
|
||||
[`components/auth/GAPS.md`](components/auth/GAPS.md).
|
||||
|
||||
| Package | Purpose | Used by |
|
||||
|---|---|---|
|
||||
@@ -105,7 +124,7 @@ follow-on** (tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md)).
|
||||
| `ZB.MOM.WW.Auth.AspNetCore` | Canonical claim types, cookie defaults, LDAP DI helpers | all (web UIs) |
|
||||
|
||||
**Consumer matrix:** OtOpcUa → `Abstractions + Ldap + AspNetCore` (no ApiKeys / SQLite);
|
||||
MxAccessGateway & ScadaBridge → all four.
|
||||
MxAccessGateway, ScadaBridge & HistorianGateway → all four packages.
|
||||
|
||||
Identity (LDAP / GLAuth, the canonical role set) is normalized; each app keeps its own
|
||||
**authorization enforcement** (OPC-UA permissions / gRPC scopes / roles + site-scoping) and maps
|
||||
@@ -117,8 +136,8 @@ and [`CANONICAL-ROLES.md`](components/auth/spec/CANONICAL-ROLES.md).
|
||||
```bash
|
||||
cd ZB.MOM.WW.Auth
|
||||
dotnet build -c Release # 0 warnings
|
||||
dotnet test # 172 passing, 1 skipped (opt-in LDAP integration test)
|
||||
dotnet pack -c Release -o ./artifacts # → 4 nupkgs @ 0.1.0
|
||||
dotnet test # 215 passing, 1 skipped (opt-in LDAP integration test) — verified 2026-07-18
|
||||
dotnet pack -c Release -o ./artifacts # → 4 nupkgs (feed is at 0.1.4)
|
||||
```
|
||||
|
||||
Stack: .NET 10 · xUnit · `Novell.Directory.Ldap.NETStandard` · `Microsoft.Data.Sqlite` · central
|
||||
@@ -133,10 +152,43 @@ ZB_LDAP_IT=1 dotnet test # requires a reachable GLAuth (e.g. a sister repo's i
|
||||
|
||||
## Roadmap
|
||||
|
||||
- ✅ Auth component normalized (spec + canonical roles + current-state + gaps).
|
||||
- ✅ `ZB.MOM.WW.Auth` shared library built and tested (0.1.0).
|
||||
- ⬜ Adopt `ZB.MOM.WW.Auth` in OtOpcUa, MxAccessGateway, ScadaBridge — [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
|
||||
- ✅ UI-theme component normalized (spec + design tokens + current-state + gaps).
|
||||
- ✅ `ZB.MOM.WW.Theme` shared UI kit built and tested (0.1.0); apps not yet adopted.
|
||||
- ⬜ Adopt `ZB.MOM.WW.Theme` in OtOpcUa [low risk], ScadaBridge [med], MxAccessGateway [high risk] — [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
||||
> **Verified 2026-07-18** against the Gitea feed (`nuget/query?q=ZB.MOM.WW`) and the actual
|
||||
> `PackageReference`s on each consumer's default branch — not from status claims in docs.
|
||||
> Per-component detail in each `components/<name>/GAPS.md`.
|
||||
|
||||
**Shipped**
|
||||
|
||||
- ✅ Auth normalized + `ZB.MOM.WW.Auth` built (feed: `0.1.0`–`0.1.5`) and **adopted by all four apps**.
|
||||
`0.1.5` (2026-07-18) pins patched SQLitePCLRaw; **mxaccessgw, ScadaBridge and HistorianGateway bumped to it**.
|
||||
OtOpcUa stays at `0.1.1` — it takes no `ApiKeys`/SQLite dependency, so it was never exposed.
|
||||
- ✅ UI Theme normalized + `ZB.MOM.WW.Theme` built (feed: `0.2.0`–`0.3.1`); **all four apps on `0.3.1`**.
|
||||
- ✅ Audit — `ZB.MOM.WW.Audit` `0.1.0` on the feed; adopted by all four (DEEP: canonical record everywhere).
|
||||
- ✅ Configuration — `ZB.MOM.WW.Configuration` `0.1.0` on the feed; adopted by all four.
|
||||
- ✅ Observability — `ZB.MOM.WW.Telemetry` + `.Serilog` `0.1.0` on the feed; adopted by all four.
|
||||
- ✅ Health — `ZB.MOM.WW.Health` (+ `.Akka`, `.EntityFrameworkCore`) `0.1.0` on the feed; **adopted by all four**
|
||||
(`MapZbHealth` wired in each; previously documented as "not yet adopted" — that was stale).
|
||||
- ✅ Galaxy Repository — `ZB.MOM.WW.GalaxyRepository` `0.2.0` on the feed; consumed by HistorianGateway
|
||||
**and mxaccessgw** (which wires `AddZbGalaxyRepository` in its Server; previously documented as a
|
||||
pending follow-on — also stale).
|
||||
- ✅ Secrets — `ZB.MOM.WW.Secrets` (+ `.Abstractions`, `.Ui`) published through **`0.1.3`** (KEK rotation,
|
||||
2026-07-18); all four apps still pinned at `0.1.2`.
|
||||
- ✅ Secrets G-7 clustered replication — **built 2026-07-18 at `0.2.0`**, both fork options:
|
||||
`ZB.MOM.WW.Secrets.Replicator.SqlServer` (shared store *and* local-plus-hub) and
|
||||
`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet` (peer-to-peer, partition-tolerant). Verified against a
|
||||
real SQL Server 2022 and an in-process 2-node Akka cluster. **Published to the feed 2026-07-18**
|
||||
(restore-verified).
|
||||
- ✅ LocalDb — `ZB.MOM.WW.LocalDb` (+ `.Contracts`, `.Replication`) built at `0.1.0` and **published to the
|
||||
feed 2026-07-18** (restore-verified from a scratch consumer).
|
||||
|
||||
**Open**
|
||||
|
||||
- ⬜ Adopt a clustered secrets topology in ScadaBridge / OtOpcUa — config + a shared KEK, no code
|
||||
change. `0.2.0` is on the feed. [`components/secrets/GAPS.md`](components/secrets/GAPS.md).
|
||||
- ⬜ Bump the four Secrets consumers `0.1.2` → `0.1.3`/`0.2.0` (KEK rotation + the same security fix).
|
||||
- ⬜ Clear the pre-existing `AngleSharp` NU1902 build break in ScadaBridge `CentralUI.Tests`
|
||||
and HistorianGateway `Tests` — both fail full-solution builds under `TreatWarningsAsErrors`.
|
||||
- ⬜ Adopt `ZB.MOM.WW.LocalDb` in an app — no consumer references it yet.
|
||||
- ⬜ Give LocalDb a `components/localdb/` entry; it is the one shared lib whose design lives only in
|
||||
[`docs/plans/2026-07-17-localdb-design.md`](docs/plans/2026-07-17-localdb-design.md).
|
||||
- ⬜ Converge the Auth version pins (OtOpcUa is three minors behind at `0.1.1`).
|
||||
- ⬜ Normalize the next cross-cutting component.
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
|
||||
set -euo pipefail
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# cd to the repo root and name the solution + an absolute output path: `dotnet pack` with
|
||||
# neither a project argument nor a fixed working directory packs whatever solution happens
|
||||
# to sit in the CALLER's directory (the bug behind the LocalDb feed incident, 0038d46).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Audit.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# push.sh — pack and push the ZB.MOM.WW.Audit NuGet package to the Gitea feed.
|
||||
#
|
||||
# Required environment variables:
|
||||
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
|
||||
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
|
||||
# GITEA_NUGET_KEY — Gitea access token with package:write permission
|
||||
#
|
||||
# Usage:
|
||||
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
|
||||
# export GITEA_NUGET_KEY="your-gitea-token"
|
||||
# ./build/push.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Audit.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
@@ -5,7 +5,10 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<!-- 0.2.1: NovellLdapConnection.Search now skips AD continuation referrals instead of
|
||||
letting them fail the search stage. Without it, a SearchBase at a domain root made
|
||||
every login fail as ServiceAccountBindFailed against a healthy directory. -->
|
||||
<Version>0.2.1</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<!-- Enable transitive pinning so the security override below (SQLitePCLRaw native lib) takes effect. -->
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -10,6 +12,9 @@
|
||||
|
||||
<!-- Data -->
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<!-- Transitive security pin: Microsoft.Data.Sqlite 10.0.7 pulls SQLitePCLRaw.lib.e_sqlite3 2.1.11,
|
||||
which carries advisory GHSA-2m69-gcr7-jv3q (NU1903). Pin the patched 2.1.12 native lib. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
|
||||
<!-- Extensions -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
|
||||
@@ -19,6 +24,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
|
||||
<!-- ASP.NET Core Authentication / Authorization -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
|
||||
@@ -29,7 +29,15 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|
||||
|
||||
## Versioning
|
||||
|
||||
All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps all four packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
All four packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.4**. A single version bump in `Directory.Build.props` bumps all four packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
|
||||
### 0.1.4 — API-key expiry (archreview G-2)
|
||||
|
||||
- **`ApiKeyRecord`/`ApiKeyListItem` gain an optional `DateTimeOffset? ExpiresUtc`** (nullable; `NULL` = never expires). `ApiKeyFailure` gains `KeyExpired`.
|
||||
- **`ApiKeyVerifier` enforces expiry** — a key whose `ExpiresUtc` is at or before "now" (inclusive, injected clock) fails with `KeyExpired`, decided before the secret comparison. Existing (never-expiring) keys are unaffected.
|
||||
- **`ApiKeyAdminCommands.CreateKeyAsync`** has a new overload taking `DateTimeOffset? expiresUtc`; the old signature delegates with `null`. `RotateKeyAsync` deliberately preserves an existing expiry (rotation changes only the secret).
|
||||
- **SQLite schema → v3**: a nullable `expires_utc` column, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotent `ALTER TABLE` in the migrator (existing rows → `NULL`). A donor v2 `gateway-auth.db` is accepted (2 ≤ 3) and upgraded in place — no key is invalidated. Old-build readers tolerate the extra column.
|
||||
- **Consumers must bump to 0.1.4** to set/enforce expiry (mxaccessgw + HistorianGateway benefit from the same verifier).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,4 +6,10 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# cd to the repo root and name the solution + an absolute output path: `dotnet pack` with
|
||||
# neither a project argument nor a fixed working directory packs whatever solution happens
|
||||
# to sit in the CALLER's directory (the bug behind the LocalDb feed incident, 0038d46).
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet pack ZB.MOM.WW.Auth.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
@@ -16,9 +16,20 @@ set -euo pipefail
|
||||
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
|
||||
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
# Named solution, named output, and a cd to the repo root, all for one reason: `dotnet pack`
|
||||
# with neither a project argument nor a fixed working directory packs whatever solution
|
||||
# happens to sit in the CALLER's directory — and then pushes it to a live feed under this
|
||||
# script's name. Invoked by absolute path from another repo's checkout, the relative form
|
||||
# packed and published 25 unrelated packages (LocalDb's copy of this script, fixed in
|
||||
# 0038d46; recovered with `dotnet nuget delete`, but only because the token happened to
|
||||
# carry delete rights). A publish script must be unambiguous about WHAT it publishes no
|
||||
# matter where it is run.
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
dotnet pack ZB.MOM.WW.Auth.slnx -c Release -o "$REPO_ROOT/artifacts"
|
||||
|
||||
dotnet nuget push "$REPO_ROOT/artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
|
||||
@@ -8,7 +8,7 @@ public sealed record ApiKeyOptions
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
|
||||
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch, KeyExpired }
|
||||
|
||||
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);
|
||||
|
||||
@@ -29,7 +29,10 @@ public interface IApiKeyVerifier
|
||||
public sealed record ApiKeyRecord(
|
||||
string KeyId, string KeyPrefix, byte[] SecretHash, string DisplayName,
|
||||
IReadOnlySet<string> Scopes, string? ConstraintsJson,
|
||||
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
|
||||
// Optional absolute expiry. NULL = never expires (the default for every key minted before this
|
||||
// field existed, so upgrading a store leaves existing keys valid). Enforced in ApiKeyVerifier.
|
||||
DateTimeOffset? ExpiresUtc = null);
|
||||
|
||||
public interface IApiKeyStore
|
||||
{
|
||||
@@ -46,7 +49,9 @@ public sealed record ApiKeyAuditEntry(string? KeyId, string EventType, string? R
|
||||
/// </summary>
|
||||
public sealed record ApiKeyListItem(
|
||||
string KeyId, string KeyPrefix, string DisplayName, IReadOnlySet<string> Scopes,
|
||||
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc);
|
||||
string? ConstraintsJson, DateTimeOffset CreatedUtc, DateTimeOffset? LastUsedUtc, DateTimeOffset? RevokedUtc,
|
||||
// Optional absolute expiry (NULL = never expires); surfaced so admins can see/alert on expiry.
|
||||
DateTimeOffset? ExpiresUtc = null);
|
||||
|
||||
public interface IApiKeyAdminStore
|
||||
{
|
||||
@@ -55,6 +60,12 @@ public interface IApiKeyAdminStore
|
||||
Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(string keyId, CancellationToken ct);
|
||||
|
||||
/// <summary>Replaces the scope set on an existing key. Does not touch the secret. Returns false if the key does not exist.</summary>
|
||||
Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct);
|
||||
|
||||
/// <summary>Enables (clears revoked_utc) or disables (sets revoked_utc) a key WITHOUT changing its secret. Returns false if the key does not exist.</summary>
|
||||
Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates all API keys as hash-free <see cref="ApiKeyListItem"/> projections, newest first.
|
||||
/// The secret hash is never selected, so callers cannot use this to recover secret material.
|
||||
|
||||
@@ -19,6 +19,17 @@ public sealed record LdapOptions
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
|
||||
/// fails with a system-side error (connect/TLS, service-account bind, or search).
|
||||
/// Each entry is <c>"host"</c> (adopts <see cref="Port"/>) or <c>"host:port"</c>.
|
||||
/// Empty (the default) disables failover entirely — single-endpoint behavior is
|
||||
/// unchanged. The TLS validation policy (<see cref="ServerCertificateValidationCallback"/>
|
||||
/// / <see cref="AllowInsecure"/>) applies to every endpoint. Endpoint preference is
|
||||
/// sticky: the service keeps using the last endpoint that answered until it fails.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FallbackServers { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
|
||||
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
|
||||
|
||||
@@ -79,11 +79,27 @@ public sealed class ApiKeyAdminCommands
|
||||
/// and returns the assembled token <c><prefix>_<keyId>_<secret></c> EXACTLY ONCE.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct) =>
|
||||
CreateKeyAsync(keyId, displayName, scopes, constraintsJson, expiresUtc: null, remoteAddress, ct);
|
||||
|
||||
/// <summary>
|
||||
/// create-key with an optional absolute expiry. A non-null <paramref name="expiresUtc"/> is
|
||||
/// persisted on the record and enforced by <see cref="ApiKeyVerifier"/>; <c>null</c> means the
|
||||
/// key never expires (the same behaviour as the expiry-less overload).
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> CreateKeyAsync(
|
||||
string keyId,
|
||||
string displayName,
|
||||
IReadOnlySet<string> scopes,
|
||||
string? constraintsJson,
|
||||
DateTimeOffset? expiresUtc,
|
||||
string? remoteAddress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
@@ -111,7 +127,8 @@ public sealed class ApiKeyAdminCommands
|
||||
ConstraintsJson: constraintsJson,
|
||||
CreatedUtc: now,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null);
|
||||
RevokedUtc: null,
|
||||
ExpiresUtc: expiresUtc);
|
||||
|
||||
await _adminStore.CreateAsync(record, ct).ConfigureAwait(false);
|
||||
await AppendAuditAsync(keyId, "create-key", remoteAddress, details: null, ct).ConfigureAwait(false);
|
||||
@@ -151,6 +168,11 @@ public sealed class ApiKeyAdminCommands
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rotation replaces only the secret (and clears revoked/last-used); it deliberately leaves any
|
||||
/// existing <see cref="ApiKeyRecord.ExpiresUtc"/> untouched, so a key's expiry survives a secret
|
||||
/// rotation. Changing a key's expiry is a create-time concern (use <see cref="CreateKeyAsync(string,string,IReadOnlySet{string},string?,DateTimeOffset?,string?,CancellationToken)"/>).
|
||||
/// </remarks>
|
||||
/// <exception cref="InvalidOperationException">The pepper is unavailable; nothing is persisted or audited.</exception>
|
||||
public async Task<CreateKeyResult> RotateKeyAsync(string keyId, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
@@ -187,6 +209,53 @@ public sealed class ApiKeyAdminCommands
|
||||
return new KeyActionResult(deleted, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// set-scopes: replaces the scope set on an existing key WITHOUT touching its secret, and
|
||||
/// appends a <c>set-scopes</c> audit entry. Only the scope count is recorded in the audit
|
||||
/// details — the scope values themselves are not logged verbatim.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> SetScopesAsync(
|
||||
string keyId, IReadOnlySet<string> scopes, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
bool updated = await _adminStore.SetScopesAsync(keyId, scopes, ct).ConfigureAwait(false);
|
||||
|
||||
string status = updated ? "scopes-set" : "not-found";
|
||||
// Record only the count, never the scope contents, to avoid leaking authority detail into audit.
|
||||
await AppendAuditAsync(keyId, "set-scopes", remoteAddress, $"{status}; count={scopes.Count}", ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(updated, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// enable-key / disable-key: reversibly toggles a key's active state WITHOUT changing its
|
||||
/// secret, and appends an <c>enable-key</c> (when enabling) or <c>disable-key</c> (when
|
||||
/// disabling) audit entry.
|
||||
/// All attempts are audited, including failures (key not found) — this is intentional to
|
||||
/// maintain a complete security trail.
|
||||
/// </summary>
|
||||
public async Task<KeyActionResult> SetEnabledAsync(
|
||||
string keyId, bool enabled, string? remoteAddress, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
DateTimeOffset now = _clock.GetUtcNow();
|
||||
bool updated = await _adminStore.SetEnabledAsync(keyId, enabled, now, ct).ConfigureAwait(false);
|
||||
|
||||
string eventType = enabled ? "enable-key" : "disable-key";
|
||||
string status = updated
|
||||
? (enabled ? "enabled" : "disabled")
|
||||
: "not-found";
|
||||
await AppendAuditAsync(keyId, eventType, remoteAddress, status, ct).ConfigureAwait(false);
|
||||
|
||||
return new KeyActionResult(updated, status);
|
||||
}
|
||||
|
||||
private string RequirePepper()
|
||||
{
|
||||
string? pepper = _pepperProvider.GetPepper();
|
||||
|
||||
@@ -56,6 +56,14 @@ public sealed class ApiKeyVerifier(
|
||||
return Fail(ApiKeyFailure.KeyRevoked);
|
||||
}
|
||||
|
||||
// 4b. Reject expired keys. Expiry is absolute and inclusive: a key whose ExpiresUtc is at or
|
||||
// before "now" is expired. A NULL ExpiresUtc never expires (the pre-expiry default), so
|
||||
// upgrading a store leaves existing keys valid. Uses the injected clock for testability.
|
||||
if (record.ExpiresUtc is { } expiresUtc && expiresUtc <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
return Fail(ApiKeyFailure.KeyExpired);
|
||||
}
|
||||
|
||||
// 5. Constant-time secret comparison.
|
||||
if (!ApiKeySecretHasher.Verify(parsed.Secret, pepper, record.SecretHash))
|
||||
{
|
||||
|
||||
@@ -4,7 +4,8 @@ using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||
namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete).
|
||||
/// SQLite-backed administration store for API keys (create, revoke, rotate, delete,
|
||||
/// set-scopes, enable/disable).
|
||||
/// </summary>
|
||||
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
|
||||
{
|
||||
@@ -20,10 +21,10 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
constraints, created_utc, last_used_utc, revoked_utc, expires_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc, $expires_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", record.KeyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", record.KeyPrefix);
|
||||
@@ -34,6 +35,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
command.Parameters.AddWithValue("$created_utc", record.CreatedUtc.ToString("O"));
|
||||
command.Parameters.AddWithValue("$last_used_utc", (object?)record.LastUsedUtc?.ToString("O") ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$revoked_utc", (object?)record.RevokedUtc?.ToString("O") ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$expires_utc", (object?)record.ExpiresUtc?.ToString("O") ?? DBNull.Value);
|
||||
|
||||
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
@@ -85,6 +87,67 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
ArgumentNullException.ThrowIfNull(scopes);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET scopes = $scopes
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$scopes", ScopeSerializer.Serialize(scopes));
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyId);
|
||||
|
||||
await using SqliteConnection connection =
|
||||
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
|
||||
// Reversible toggle: NO `revoked_utc IS NULL` guard (unlike RevokeAsync), so it works
|
||||
// regardless of current state. Deliberately leaves secret_hash and last_used_utc untouched
|
||||
// — that is what distinguishes re-enable from RotateAsync.
|
||||
if (enabled)
|
||||
{
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = NULL
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
}
|
||||
else
|
||||
{
|
||||
command.CommandText = """
|
||||
UPDATE api_keys
|
||||
SET revoked_utc = $revoked_utc
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$revoked_utc", whenUtc.ToString("O"));
|
||||
}
|
||||
|
||||
int rows = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
@@ -116,7 +179,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
// Deliberately omits secret_hash so listing can never leak secret material.
|
||||
command.CommandText = """
|
||||
SELECT key_id, key_prefix, display_name, scopes, constraints,
|
||||
created_utc, last_used_utc, revoked_utc
|
||||
created_utc, last_used_utc, revoked_utc, expires_utc
|
||||
FROM api_keys
|
||||
ORDER BY created_utc DESC, key_id DESC;
|
||||
""";
|
||||
@@ -135,7 +198,8 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
ConstraintsJson: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(5)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 6),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7)));
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
|
||||
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 8)));
|
||||
}
|
||||
|
||||
return items;
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
|
||||
{
|
||||
private const string SelectColumns =
|
||||
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc";
|
||||
"key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc, expires_utc";
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
@@ -72,5 +72,6 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
|
||||
ConstraintsJson: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
CreatedUtc: SqliteValueParsing.ParseUtc(reader.GetString(6)),
|
||||
LastUsedUtc: SqliteValueParsing.ReadNullableUtc(reader, 7),
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8));
|
||||
RevokedUtc: SqliteValueParsing.ReadNullableUtc(reader, 8),
|
||||
ExpiresUtc: SqliteValueParsing.ReadNullableUtc(reader, 9));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,17 @@ namespace ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||
/// </summary>
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
/// <summary>The schema version this build creates and supports.</summary>
|
||||
public const int CurrentVersion = 1;
|
||||
/// <summary>
|
||||
/// The schema version this build creates and supports. Versions <c>1</c> and <c>2</c> shared a
|
||||
/// byte-identical schema (the store was extracted from the donor MxAccessGateway, whose deployed
|
||||
/// <c>gateway-auth.db</c> stamps <c>version = 2</c>); <c>3</c> adds the nullable
|
||||
/// <c>expires_utc</c> column (API-key expiry, archreview G-2). The migration is additive and
|
||||
/// backward-compatible: an existing v1/v2 database is upgraded in place (the column is added via
|
||||
/// an idempotent <c>ALTER TABLE</c>, existing rows get <c>NULL</c> = never-expires) and re-stamped
|
||||
/// to 3, so no key is invalidated on upgrade. The migrator still only refuses an on-disk version
|
||||
/// <em>newer</em> than this, so a donor v2 database is accepted (2 <= 3) and upgraded.
|
||||
/// </summary>
|
||||
public const int CurrentVersion = 3;
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
@@ -27,6 +36,11 @@ public static class SqliteAuthSchema
|
||||
""";
|
||||
|
||||
/// <summary>DDL creating the API-key record table.</summary>
|
||||
/// <remarks>
|
||||
/// <c>expires_utc</c> (nullable) was added at schema v3. Fresh databases get it from this DDL;
|
||||
/// existing v1/v2 databases get it via the idempotent <c>ALTER TABLE</c> in
|
||||
/// <see cref="SqliteAuthStoreMigrator"/> (<see cref="AddExpiresUtcColumn"/>).
|
||||
/// </remarks>
|
||||
public const string CreateApiKeysTable = """
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
@@ -37,10 +51,22 @@ public static class SqliteAuthSchema
|
||||
constraints TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
revoked_utc TEXT NULL,
|
||||
expires_utc TEXT NULL
|
||||
);
|
||||
""";
|
||||
|
||||
/// <summary>Name of the nullable expiry column added at schema v3.</summary>
|
||||
public const string ExpiresUtcColumn = "expires_utc";
|
||||
|
||||
/// <summary>
|
||||
/// Idempotent forward migration adding <see cref="ExpiresUtcColumn"/> to a pre-v3
|
||||
/// <c>api_keys</c> table. Guarded by a column-existence check at the call site, so it is safe to
|
||||
/// run against a fresh (already-has-the-column) database too.
|
||||
/// </summary>
|
||||
public const string AddExpiresUtcColumn =
|
||||
"ALTER TABLE api_keys ADD COLUMN expires_utc TEXT NULL;";
|
||||
|
||||
/// <summary>DDL creating the append-only audit table.</summary>
|
||||
public const string CreateApiKeyAuditTable = """
|
||||
CREATE TABLE IF NOT EXISTS api_key_audit (
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await ApplySchemaAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -78,7 +78,11 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
: Convert.ToInt32(version, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionOneAsync(
|
||||
// Single-shot create of the final schema (all DDL is CREATE ... IF NOT EXISTS, so it is
|
||||
// idempotent against an already-provisioned database), followed by any additive column
|
||||
// migrations that a pre-existing table would be missing. The applied version is stamped
|
||||
// separately by WriteSchemaVersionAsync.
|
||||
private static async Task ApplySchemaAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -93,6 +97,54 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
SqliteAuthSchema.CreateApiKeyAuditTable,
|
||||
SqliteAuthSchema.CreateIndexes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// v3: add the nullable expires_utc column to a pre-v3 api_keys table. On a fresh database the
|
||||
// CREATE above already includes the column, so the existence check makes this a no-op.
|
||||
await EnsureApiKeysColumnAsync(
|
||||
connection,
|
||||
transaction,
|
||||
SqliteAuthSchema.ExpiresUtcColumn,
|
||||
SqliteAuthSchema.AddExpiresUtcColumn,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Adds a column to api_keys only if it is not already present. SQLite has no
|
||||
// "ADD COLUMN IF NOT EXISTS", so we probe PRAGMA table_info first. The reader is fully consumed
|
||||
// and disposed before the ALTER runs on the same connection/transaction.
|
||||
private static async Task EnsureApiKeysColumnAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string columnName,
|
||||
string addColumnDdl,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool exists = false;
|
||||
|
||||
await using (SqliteCommand pragma = connection.CreateCommand())
|
||||
{
|
||||
pragma.Transaction = transaction;
|
||||
// table_info takes an identifier, not a bindable parameter; the table name is a fixed
|
||||
// internal constant, never caller input.
|
||||
pragma.CommandText = $"PRAGMA table_info({SqliteAuthSchema.ApiKeysTable});";
|
||||
|
||||
await using SqliteDataReader reader =
|
||||
await pragma.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// PRAGMA table_info columns: cid(0), name(1), type(2), ...
|
||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
await ExecuteNonQueryAsync(connection, transaction, addColumnDdl, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteSchemaVersionAsync(
|
||||
|
||||
@@ -49,11 +49,16 @@ public static class ServiceCollectionExtensions
|
||||
// Fail fast at startup on a misconfigured directory rather than on first login.
|
||||
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
|
||||
|
||||
// LdapAuthService is stateless: it holds only a snapshot of LdapOptions and a stateless
|
||||
// connection factory, and opens/disposes a connection per call. It is not IDisposable.
|
||||
// Singleton is correct; TryAdd mirrors the pattern in AddZbApiKeyAuth (idempotency).
|
||||
// LdapAuthService opens/disposes a connection per call and is not IDisposable. Its only
|
||||
// mutable state is the sticky endpoint preference used by FallbackServers failover —
|
||||
// which is precisely why the instance must be shared, so a primary-DC outage is paid
|
||||
// for once rather than on every login. Singleton is correct; TryAdd mirrors the pattern
|
||||
// in AddZbApiKeyAuth (idempotency). The logger is resolved with GetService, not
|
||||
// GetRequiredService, so a host without logging configured still resolves the service.
|
||||
services.TryAddSingleton<ILdapAuthService>(sp =>
|
||||
new LdapAuthService(sp.GetRequiredService<IOptions<LdapOptions>>().Value));
|
||||
new LdapAuthService(
|
||||
sp.GetRequiredService<IOptions<LdapOptions>>().Value,
|
||||
sp.GetService<Microsoft.Extensions.Logging.ILogger<LdapAuthService>>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>A single LDAP server endpoint resolved from configuration.</summary>
|
||||
internal sealed record LdapEndpoint(string Host, int Port)
|
||||
{
|
||||
/// <summary>Renders the endpoint as <c>host:port</c> — the form used in failover log messages.</summary>
|
||||
public override string ToString() => $"{Host}:{Port}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>FallbackServers</c> entries: <c>"host"</c> (adopts the caller's default port)
|
||||
/// or <c>"host:port"</c>. IPv6 literals are not supported — use a DNS name.
|
||||
/// </summary>
|
||||
internal static class LdapEndpointParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to parse a single <c>FallbackServers</c> entry.
|
||||
/// </summary>
|
||||
/// <param name="entry">The configured entry, <c>"host"</c> or <c>"host:port"</c>. Surrounding whitespace is tolerated.</param>
|
||||
/// <param name="defaultPort">Port adopted when the entry carries no explicit one.</param>
|
||||
/// <param name="endpoint">The parsed endpoint on success; undefined otherwise.</param>
|
||||
/// <param name="error">A human-readable reason on failure; empty on success.</param>
|
||||
/// <returns><see langword="true"/> when the entry parsed.</returns>
|
||||
public static bool TryParse(string entry, int defaultPort, out LdapEndpoint endpoint, out string error)
|
||||
{
|
||||
endpoint = null!;
|
||||
error = "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry))
|
||||
{
|
||||
error = "entry is empty or whitespace";
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = entry.Trim().Split(':');
|
||||
if (parts.Length > 2)
|
||||
{
|
||||
error = $"entry \"{entry.Trim()}\" contains more than one ':' " +
|
||||
"(IPv6 literals are not supported — use a DNS name)";
|
||||
return false;
|
||||
}
|
||||
|
||||
var host = parts[0].Trim();
|
||||
if (host.Length == 0)
|
||||
{
|
||||
error = $"entry \"{entry.Trim()}\" has an empty host";
|
||||
return false;
|
||||
}
|
||||
|
||||
var port = defaultPort;
|
||||
if (parts.Length == 2 && (!int.TryParse(parts[1].Trim(), out port) || port is < 1 or > 65535))
|
||||
{
|
||||
error = $"entry \"{entry.Trim()}\" has an invalid port (must be 1–65535)";
|
||||
return false;
|
||||
}
|
||||
|
||||
endpoint = new LdapEndpoint(host, port);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -99,23 +99,73 @@ internal sealed class NovellLdapConnection : ILdapConnection
|
||||
attributes.ToArray(),
|
||||
typesOnly: false);
|
||||
|
||||
return ReadEntries(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains a result set into entries, <b>skipping continuation referrals</b>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Active Directory answers any search based at a domain root with one or more
|
||||
/// continuation references (to <c>CN=Configuration</c>, <c>DC=ForestDnsZones</c> and
|
||||
/// friends). With referral following disabled — the default, and what we want, since
|
||||
/// chasing a referral would re-bind somewhere we did not authenticate — Novell surfaces
|
||||
/// each one by throwing <see cref="LdapReferralException"/> from <see cref="ILdapSearchResults.Next"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A continuation reference is a <em>pointer to another naming context</em>, not an error:
|
||||
/// it says "there may be more results over there", never that this search failed. Letting
|
||||
/// it escape made the whole search stage fail, which <c>LdapAuthService</c> maps to
|
||||
/// <see cref="Abstractions.Ldap.LdapAuthFailure.ServiceAccountBindFailed"/> — so against a
|
||||
/// real AD domain root, every login failed with a system-side error while the directory,
|
||||
/// the credentials and both DCs were perfectly healthy. Verified against
|
||||
/// <c>DC=nam,DC=zimmer,DC=com</c> on 2026-08-12.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipping is the correct behaviour rather than a workaround: the entries we can see are
|
||||
/// exactly the ones in the naming context we bound to, which is the set the caller asked
|
||||
/// about. Internal so the referral handling can be unit-tested through the
|
||||
/// <see cref="ILdapSearchResults"/> seam without a live directory.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static List<LdapSearchEntry> ReadEntries(ILdapSearchResults results)
|
||||
{
|
||||
var entries = new List<LdapSearchEntry>();
|
||||
while (results.HasMore())
|
||||
var referrals = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var entry = results.Next();
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
||||
try
|
||||
{
|
||||
attrs[attr.Name] = attr.StringValueArray.ToList();
|
||||
}
|
||||
if (!results.HasMore()) break;
|
||||
|
||||
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
|
||||
var entry = results.Next();
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (LdapAttribute attr in entry.GetAttributeSet())
|
||||
{
|
||||
attrs[attr.Name] = attr.StringValueArray.ToList();
|
||||
}
|
||||
|
||||
entries.Add(new LdapSearchEntry(entry.Dn, attrs));
|
||||
}
|
||||
catch (LdapException ex) when (ex is LdapReferralException || ex.ResultCode == LdapException.Referral)
|
||||
{
|
||||
// Novell dequeues the reference before throwing, so the enumeration advances and
|
||||
// this terminates. The cap is a belt-and-braces guard: a client that threw without
|
||||
// advancing would otherwise spin forever inside a fail-closed auth path, which is a
|
||||
// far worse failure than under-reporting an implausible number of referrals.
|
||||
if (++referrals > MaxReferrals) break;
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on continuation references tolerated in one search — see <see cref="ReadEntries"/>.</summary>
|
||||
private const int MaxReferrals = 1_000;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
@@ -11,6 +12,7 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// <see cref="ILdapConnection"/> so the logic is unit-testable without a live server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Fully fail-closed: authentication never throws to the caller — every error, expected
|
||||
/// or unexpected, is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>.
|
||||
/// A success is only returned for a user that resolved to exactly one entry, whose password
|
||||
@@ -18,11 +20,38 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// Service-account bind failures (<see cref="LdapAuthFailure.ServiceAccountBindFailed"/>) are
|
||||
/// kept distinct from end-user bind failures (<see cref="LdapAuthFailure.BadCredentials"/>) so
|
||||
/// a system misconfiguration is not mistaken for bad user input.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <see cref="LdapOptions.FallbackServers"/> is non-empty the flow above runs against
|
||||
/// an ordered endpoint list (primary first, then fallbacks in declared order) and moves on
|
||||
/// to the next endpoint ONLY on a system-side failure — connect/TLS, service-account bind,
|
||||
/// or search — i.e. when the endpoint gave no authoritative answer. Any answer from a healthy
|
||||
/// directory (success, or a user-side failure such as
|
||||
/// <see cref="LdapAuthFailure.BadCredentials"/> / <see cref="LdapAuthFailure.UserNotFound"/>)
|
||||
/// is final and is never retried elsewhere: retrying a rejected password against a second DC
|
||||
/// would turn one bad-password event into several, tripping account lockouts. Endpoint
|
||||
/// preference is sticky — the last endpoint that answered is tried first next time — so a
|
||||
/// primary outage costs its connect timeout once, not once per login.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The service is safe to share (it is registered as a singleton). The sticky index is the
|
||||
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
|
||||
/// is one login starting at a stale endpoint and walking on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly ILdapConnectionFactory _connectionFactory;
|
||||
private readonly IReadOnlyList<LdapEndpoint> _endpoints;
|
||||
private readonly ILogger<LdapAuthService>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Index into <see cref="_endpoints"/> of the endpoint tried first. Written on any
|
||||
/// authoritative answer from a non-preferred endpoint. Accessed via
|
||||
/// <see cref="Volatile"/> only; benign races are accepted (see the type remarks).
|
||||
/// </summary>
|
||||
private int _preferredIndex;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: binds against a live directory via the real
|
||||
@@ -31,9 +60,15 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
|
||||
/// connection is built with it.
|
||||
/// </summary>
|
||||
public LdapAuthService(LdapOptions options)
|
||||
/// <param name="options">The directory connection and search settings.</param>
|
||||
/// <param name="logger">
|
||||
/// Optional. When supplied, endpoint failover is made visible: a Warning per failed
|
||||
/// endpoint hop and an Information line when the sticky preference moves. Omitting it
|
||||
/// (the default) leaves the service silent — hosts without logging still work.
|
||||
/// </param>
|
||||
public LdapAuthService(LdapOptions options, ILogger<LdapAuthService>? logger = null)
|
||||
: this(options, new NovellLdapConnectionFactory(
|
||||
options.AllowInsecure, options.ServerCertificateValidationCallback))
|
||||
options.AllowInsecure, options.ServerCertificateValidationCallback), logger)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -42,10 +77,33 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
/// so the bind/search logic can be exercised without a live directory. Internal
|
||||
/// because the connection seam is an implementation detail.
|
||||
/// </summary>
|
||||
internal LdapAuthService(LdapOptions options, ILdapConnectionFactory connectionFactory)
|
||||
internal LdapAuthService(
|
||||
LdapOptions options,
|
||||
ILdapConnectionFactory connectionFactory,
|
||||
ILogger<LdapAuthService>? logger = null)
|
||||
{
|
||||
_options = options;
|
||||
_connectionFactory = connectionFactory;
|
||||
_logger = logger;
|
||||
_endpoints = BuildEndpoints(options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Primary first, then fallbacks in declared order. The validator rejects malformed
|
||||
/// entries at boot; unparseable entries are skipped defensively here so a service
|
||||
/// constructed without options validation degrades to the parseable subset rather
|
||||
/// than throwing from a constructor.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<LdapEndpoint> BuildEndpoints(LdapOptions options)
|
||||
{
|
||||
var endpoints = new List<LdapEndpoint> { new(options.Server, options.Port) };
|
||||
foreach (var entry in options.FallbackServers)
|
||||
{
|
||||
if (LdapEndpointParser.TryParse(entry, options.Port, out var endpoint, out _))
|
||||
endpoints.Add(endpoint);
|
||||
}
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -54,7 +112,10 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
/// bind-then-search-then-bind flow is wrapped so that any error — expected or unexpected —
|
||||
/// is mapped to a structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>. A
|
||||
/// <c>Succeeded == true</c> result is only ever returned when the user resolved to exactly
|
||||
/// one entry, their password verified, AND at least one group was extracted.
|
||||
/// one entry, their password verified, AND at least one group was extracted. With
|
||||
/// <see cref="LdapOptions.FallbackServers"/> configured the flow may run against more than
|
||||
/// one endpoint before answering — see the type-level remarks for the walk and its
|
||||
/// system-side-only failover rule.
|
||||
/// </remarks>
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
@@ -80,25 +141,90 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
// filter and the returned result (avoids two identities for one person).
|
||||
username = username.Trim();
|
||||
|
||||
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
||||
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
|
||||
var preferred = Volatile.Read(ref _preferredIndex);
|
||||
if ((uint)preferred >= (uint)_endpoints.Count)
|
||||
preferred = 0; // defensive: the list is immutable, but never index out of it
|
||||
|
||||
// Seeded so an exhausted walk still returns the conservative system-side bucket
|
||||
// even in the impossible case of an empty endpoint list.
|
||||
var lastSystemSideFailure = LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
|
||||
foreach (var index in WalkOrder(_endpoints.Count, preferred))
|
||||
{
|
||||
var result = AuthenticateAgainst(_endpoints[index], username, password);
|
||||
|
||||
// ServiceAccountBindFailed is the (sole) system-side bucket: this endpoint gave
|
||||
// no authoritative answer, so try the next one. Every other outcome — success, or
|
||||
// a user-side failure like UserNotFound/BadCredentials — came from a healthy
|
||||
// directory and is FINAL: re-asking a second DC about a rejected password would
|
||||
// multiply one bad-password event into several against the account lockout policy.
|
||||
if (result.Failure == LdapAuthFailure.ServiceAccountBindFailed)
|
||||
{
|
||||
lastSystemSideFailure = result;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sticky preference: any authoritative answer proves this endpoint healthy.
|
||||
// Updating only on Succeeded would re-pay the dead primary's connect timeout on
|
||||
// every mistyped password for the whole outage.
|
||||
if (index != preferred)
|
||||
{
|
||||
Volatile.Write(ref _preferredIndex, index);
|
||||
_logger?.LogInformation("LDAP sticky preference moved to {Endpoint}.", _endpoints[index]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Every endpoint failed system-side. The preference is deliberately left where it was:
|
||||
// a total outage says nothing about which endpoint to prefer once one recovers.
|
||||
return lastSystemSideFailure;
|
||||
}
|
||||
|
||||
/// <summary>Preferred endpoint first, then every other endpoint in declared order.</summary>
|
||||
private static IEnumerable<int> WalkOrder(int count, int preferred)
|
||||
{
|
||||
yield return preferred;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (i != preferred)
|
||||
yield return i;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one full bind-then-search-then-bind attempt against a single endpoint on a fresh
|
||||
/// connection. The fail-closed contract holds per attempt: this never throws, and it
|
||||
/// returns <see cref="LdapAuthFailure.ServiceAccountBindFailed"/> for — and only for —
|
||||
/// system-side faults, which is what the caller reads as "no authoritative answer, try
|
||||
/// the next endpoint".
|
||||
/// </summary>
|
||||
private LdapAuthResult AuthenticateAgainst(LdapEndpoint endpoint, string username, string password)
|
||||
{
|
||||
// The whole flow runs inside an outer fail-closed guard: a StageFailure carries an
|
||||
// already-mapped failure out of a stage, and any OTHER unexpected exception defaults to
|
||||
// the most conservative system-side bucket. Either way the caller gets a structured result.
|
||||
try
|
||||
{
|
||||
// A fresh connection per attempt: a Novell connection that has failed to connect
|
||||
// or bind is not safely reusable against another host.
|
||||
using var conn = _connectionFactory.Create();
|
||||
|
||||
// 4. Open the connection (transport/TLS handling lives in the adapter). The
|
||||
// 5. Open the connection (transport/TLS handling lives in the adapter). The
|
||||
// per-operation timeout (ConnectionTimeoutMs) is applied by the adapter here.
|
||||
// A failure to connect/upgrade means the directory is unreachable — a
|
||||
// system-side fault, not the user's, so map it to ServiceAccountBindFailed.
|
||||
// A failure to connect/upgrade means this endpoint is unreachable — a
|
||||
// system-side fault, not the user's, so map it to ServiceAccountBindFailed
|
||||
// and let the caller try the next endpoint.
|
||||
// NOTE: the LdapAuthFailure enum has no dedicated DirectoryUnavailable value;
|
||||
// ServiceAccountBindFailed is the closest system-side bucket. A future
|
||||
// Abstractions change could add DirectoryUnavailable to disambiguate.
|
||||
try
|
||||
{
|
||||
conn.Connect(
|
||||
_options.Server,
|
||||
_options.Port,
|
||||
endpoint.Host,
|
||||
endpoint.Port,
|
||||
_options.Transport,
|
||||
_options.AllowInsecure,
|
||||
_options.ConnectionTimeoutMs,
|
||||
@@ -106,10 +232,10 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
|
||||
}
|
||||
|
||||
// 5. Service-account bind so we can search for the user's DN. A bind failure
|
||||
// 6. Service-account bind so we can search for the user's DN. A bind failure
|
||||
// here is a service-account misconfiguration — DISTINCT from a user-credential
|
||||
// failure — so it maps to ServiceAccountBindFailed.
|
||||
try
|
||||
@@ -118,10 +244,10 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 6. Search for the user entry by the configured username attribute. A search
|
||||
// 7. Search for the user entry by the configured username attribute. A search
|
||||
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
|
||||
// closest system-side bucket until a DirectoryUnavailable value exists.
|
||||
@@ -133,10 +259,11 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||
}
|
||||
|
||||
// 7. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
@@ -145,19 +272,21 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 8. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials.
|
||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials — the directory answered, so this is
|
||||
// returned directly and never retried against another endpoint.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.BadCredentials);
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 9. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success.
|
||||
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
|
||||
// too is an answer about the user, not about the endpoint, so it ends the walk.
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
@@ -165,25 +294,31 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.GroupLookupFailed);
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
// 10. Success — and only here, with a verified password and >= 1 group.
|
||||
// 11. Success — and only here, with a verified password and >= 1 group.
|
||||
var displayName = ExtractDisplayName(entry, username);
|
||||
return LdapAuthResult.Success(username, displayName, groups);
|
||||
}
|
||||
catch (StageFailure stage)
|
||||
{
|
||||
// A stage mapped its own failure; surface it as a structured result.
|
||||
// A stage mapped its own failure; surface it as a structured result. Log at Warning
|
||||
// so a silent failover — the failure mode this feature exists to survive — is
|
||||
// visible in the host's log rather than only in a latency graph.
|
||||
_logger?.LogWarning(
|
||||
"LDAP endpoint {Endpoint} failed system-side at {Stage}; treating as no answer.",
|
||||
endpoint, stage.Stage);
|
||||
return LdapAuthResult.Fail(stage.Failure);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Belt-and-braces: ANY unexpected exception fails closed to the most conservative
|
||||
// system-side bucket rather than propagating to the caller.
|
||||
_logger?.LogWarning(ex, "LDAP endpoint {Endpoint} failed system-side with an unexpected error.", endpoint);
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.ServiceAccountBindFailed);
|
||||
}
|
||||
}
|
||||
@@ -194,9 +329,16 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
/// </summary>
|
||||
private sealed class StageFailure : Exception
|
||||
{
|
||||
public StageFailure(LdapAuthFailure failure) => Failure = failure;
|
||||
public StageFailure(LdapAuthFailure failure, string stage)
|
||||
{
|
||||
Failure = failure;
|
||||
Stage = stage;
|
||||
}
|
||||
|
||||
public LdapAuthFailure Failure { get; }
|
||||
|
||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||
public string Stage { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// low-level error on the first real login attempt.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Four conditions are enforced:
|
||||
/// Validation is skipped entirely when <see cref="LdapOptions.Enabled"/> is <c>false</c>
|
||||
/// (a disabled provider's connection fields are inert). When enabled, five conditions
|
||||
/// are enforced:
|
||||
/// <list type="bullet">
|
||||
/// <item>plaintext transport (<see cref="LdapTransport.None"/>) is rejected unless
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> is explicitly set (dev/test only);</item>
|
||||
@@ -17,7 +19,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow.</item>
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
|
||||
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
|
||||
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
|
||||
/// until the primary outage it exists to survive.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
@@ -27,6 +32,14 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
// When LDAP is disabled, its connection fields are inert — do not require them.
|
||||
// A consumer that turns LDAP off should not have to supply a server/search-base/
|
||||
// service-account just to satisfy startup validation.
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
@@ -58,6 +71,21 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
"(e.g. \"cn=svc,dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
// Fallback endpoints are walked by LdapAuthService on system-side failures. A
|
||||
// malformed entry would be silently skipped there (the walk degrades to the
|
||||
// parseable subset rather than throwing), so it must be caught at boot instead —
|
||||
// otherwise an operator's typo'd backup DC would only surface as a failed
|
||||
// failover during the primary outage it was meant to survive.
|
||||
foreach (var entry in options.FallbackServers)
|
||||
{
|
||||
if (!Internal.LdapEndpointParser.TryParse(entry, options.Port, out _, out var error))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.FallbackServers)} contains an invalid entry — {error}. " +
|
||||
"Each entry must be \"host\" or \"host:port\" (port 1–65535).");
|
||||
}
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -87,6 +87,46 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
Assert.Single(recent, e => e.EventType == "create-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_WithExpiresUtc_PersistsExpiryOnRecord()
|
||||
{
|
||||
// archreview G-2: the expiry overload threads ExpiresUtc onto the stored record.
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
var expiry = new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
await commands.CreateKeyAsync(
|
||||
"key-exp",
|
||||
"Service Exp",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
expiresUtc: expiry,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(expiry, found!.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_ExpiryLessOverload_PersistsNullExpiry()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Null(found!.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
|
||||
{
|
||||
@@ -292,6 +332,59 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
Assert.Equal(auditCountBefore, auditCountAfter);
|
||||
}
|
||||
|
||||
// --- set-scopes / enable-disable ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_And_SetScopesAsync_AppendAuditEntries()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
null,
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
// Disable, then re-enable, then replace scopes.
|
||||
KeyActionResult disabled =
|
||||
await commands.SetEnabledAsync("key-1", enabled: false, "10.0.0.1", CancellationToken.None);
|
||||
Assert.True(disabled.Succeeded);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
KeyActionResult enabled =
|
||||
await commands.SetEnabledAsync("key-1", enabled: true, "10.0.0.1", CancellationToken.None);
|
||||
Assert.True(enabled.Succeeded);
|
||||
Assert.NotNull(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
|
||||
KeyActionResult scoped = await commands.SetScopesAsync(
|
||||
"key-1",
|
||||
new HashSet<string>(["read", "write"], StringComparer.Ordinal),
|
||||
"10.0.0.1",
|
||||
CancellationToken.None);
|
||||
Assert.True(scoped.Succeeded);
|
||||
|
||||
IReadOnlyList<ApiKeyAuditEntry> recent = await _audit.ListRecentAsync(50, CancellationToken.None);
|
||||
Assert.Single(recent, e => e.EventType == "disable-key");
|
||||
Assert.Single(recent, e => e.EventType == "enable-key");
|
||||
Assert.Single(recent, e => e.EventType == "set-scopes");
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["read", "write"], StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_NullScopes_Throws()
|
||||
{
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() =>
|
||||
commands.SetScopesAsync("key-1", null!, null, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- delete-key ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -20,7 +20,8 @@ public class ApiKeyVerifierTests
|
||||
|
||||
private static ApiKeyRecord BuildRecord(
|
||||
byte[] secretHash,
|
||||
DateTimeOffset? revokedUtc = null) => new(
|
||||
DateTimeOffset? revokedUtc = null,
|
||||
DateTimeOffset? expiresUtc = null) => new(
|
||||
KeyId: KeyId,
|
||||
KeyPrefix: TokenPrefix,
|
||||
SecretHash: secretHash,
|
||||
@@ -29,7 +30,8 @@ public class ApiKeyVerifierTests
|
||||
ConstraintsJson: ConstraintsJson,
|
||||
CreatedUtc: DateTimeOffset.UnixEpoch,
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: revokedUtc);
|
||||
RevokedUtc: revokedUtc,
|
||||
ExpiresUtc: expiresUtc);
|
||||
|
||||
private static ApiKeyVerifier BuildVerifier(
|
||||
FakeApiKeyStore store,
|
||||
@@ -126,6 +128,101 @@ public class ApiKeyVerifierTests
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
// --- KeyExpired (archreview G-2) ---
|
||||
|
||||
private static readonly DateTimeOffset Now = new(2026, 6, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static ApiKeyVerifier BuildVerifierAt(
|
||||
FakeApiKeyStore store, DateTimeOffset now) =>
|
||||
new(new ApiKeyOptions { TokenPrefix = TokenPrefix },
|
||||
store,
|
||||
new FakePepperProvider(Pepper),
|
||||
new FakeTimeProvider(now));
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ExpiredKey_ReturnsKeyExpired()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash, expiresUtc: Now - TimeSpan.FromSeconds(1)),
|
||||
};
|
||||
var verifier = BuildVerifierAt(store, Now);
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
|
||||
Assert.Null(result.Identity);
|
||||
// Expiry is decided before the secret comparison and usage write.
|
||||
Assert.False(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ExpiryExactlyNow_IsExpired_Inclusive()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: Now) };
|
||||
var verifier = BuildVerifierAt(store, Now);
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.KeyExpired, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NotYetExpiredKey_Succeeds()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash, expiresUtc: Now + TimeSpan.FromSeconds(1)),
|
||||
};
|
||||
var verifier = BuildVerifierAt(store, Now);
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Null(result.Failure);
|
||||
Assert.True(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_NullExpiry_NeverExpires()
|
||||
{
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore { Record = BuildRecord(hash, expiresUtc: null) };
|
||||
// A clock far in the future must still accept a never-expiring key.
|
||||
var verifier = BuildVerifierAt(store, Now + TimeSpan.FromDays(3650));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_RevokedTakesPrecedenceOverExpiry()
|
||||
{
|
||||
// A key that is both revoked and expired reports KeyRevoked (the earlier check).
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash, revokedUtc: Now - TimeSpan.FromDays(1), expiresUtc: Now - TimeSpan.FromDays(1)),
|
||||
};
|
||||
var verifier = BuildVerifierAt(store, Now);
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
|
||||
}
|
||||
|
||||
// --- SecretMismatch ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -41,6 +41,34 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
Assert.Null(found.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithExpiresUtc_RoundTripsThroughFindAndList()
|
||||
{
|
||||
var expiry = new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero);
|
||||
ApiKeyRecord record = SampleRecord("key-exp") with { ExpiresUtc = expiry };
|
||||
|
||||
await _admin.CreateAsync(record, CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-exp", CancellationToken.None);
|
||||
Assert.Equal(expiry, found!.ExpiresUtc);
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-exp");
|
||||
Assert.Equal(expiry, item.ExpiresUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutExpiresUtc_ReadsBackAsNull()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Null(found!.ExpiresUtc);
|
||||
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
Assert.Null(Assert.Single(listed, k => k.KeyId == "key-1").ExpiresUtc);
|
||||
}
|
||||
|
||||
// --- Revoke ---
|
||||
|
||||
[Fact]
|
||||
@@ -105,6 +133,87 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetScopes ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_ReplacesScopes_AndReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(
|
||||
SampleRecord("key-1") with { Scopes = new HashSet<string>(["a"], StringComparer.Ordinal) },
|
||||
CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"key-1",
|
||||
new HashSet<string>(["b", "c"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.True(item.Scopes.SetEquals(new HashSet<string>(["b", "c"], StringComparer.Ordinal)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.SetScopesAsync(
|
||||
"missing",
|
||||
new HashSet<string>(["b"], StringComparer.Ordinal),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- SetEnabled ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_False_DisablesKey()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var when = new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, when, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Null(await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None));
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.Equal(when, found!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_True_ReenablesKey_WithoutChangingSecret()
|
||||
{
|
||||
ApiKeyRecord original = SampleRecord("key-1");
|
||||
await _admin.CreateAsync(original, CancellationToken.None);
|
||||
// Record some usage so we can prove last_used_utc is left untouched on re-enable.
|
||||
var used = new DateTimeOffset(2026, 5, 20, 12, 0, 0, TimeSpan.Zero);
|
||||
await _read.MarkUsedAsync("key-1", used, CancellationToken.None);
|
||||
|
||||
// Disable, then re-enable.
|
||||
await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: false, new DateTimeOffset(2026, 5, 31, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, new DateTimeOffset(2026, 6, 1, 9, 0, 0, TimeSpan.Zero), CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
// Active again, and the secret hash + last-used timestamp are unchanged.
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.True(active!.SecretHash.SequenceEqual(original.SecretHash));
|
||||
Assert.Null(active.RevokedUtc);
|
||||
Assert.Equal(used, active.LastUsedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_UnknownKey_ReturnsFalse()
|
||||
{
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"missing", enabled: false, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
// --- Delete ---
|
||||
|
||||
[Fact]
|
||||
@@ -172,6 +281,73 @@ public sealed class SqliteApiKeyAdminStoreTests : IAsyncLifetime
|
||||
() => _admin.DeleteAsync(keyId!, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetScopesAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetScopesAsync(
|
||||
keyId!,
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task SetEnabledAsync_NullOrWhitespaceKeyId_ThrowsArgumentException(string? keyId)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(
|
||||
() => _admin.SetEnabledAsync(keyId!, enabled: false, DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetScopesAsync_NullScopes_ThrowsArgumentNullException()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => _admin.SetScopesAsync("key-1", null!, CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- SetEnabled idempotence ---
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_OnAlreadyActiveKey_ReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
|
||||
bool result = await _admin.SetEnabledAsync(
|
||||
"key-1", enabled: true, DateTimeOffset.UtcNow, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
ApiKeyRecord? active = await _read.FindActiveByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(active);
|
||||
Assert.Null(active!.RevokedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetEnabledAsync_OnAlreadyDisabledKey_OverwritesTimestamp_ReturnsTrue()
|
||||
{
|
||||
await _admin.CreateAsync(SampleRecord("key-1"), CancellationToken.None);
|
||||
var t1 = new DateTimeOffset(2026, 5, 1, 10, 0, 0, TimeSpan.Zero);
|
||||
var t2 = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// Disable at t1.
|
||||
await _admin.SetEnabledAsync("key-1", enabled: false, t1, CancellationToken.None);
|
||||
|
||||
// Disable again at a later t2 (idempotent overwrite — no guard on revoked_utc).
|
||||
bool result = await _admin.SetEnabledAsync("key-1", enabled: false, t2, CancellationToken.None);
|
||||
|
||||
Assert.True(result);
|
||||
IReadOnlyList<ApiKeyListItem> listed = await _admin.ListAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.Equal(t2, item.RevokedUtc);
|
||||
}
|
||||
|
||||
// --- Audit ---
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -34,6 +34,96 @@ public sealed class SqliteMigratorTests : IDisposable
|
||||
Assert.Equal(1, await CountSchemaVersionRowsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentVersion_Is3_ForExpiresUtcColumn() =>
|
||||
// v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the
|
||||
// migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot:
|
||||
// 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant.
|
||||
Assert.Equal(3, SqliteAuthSchema.CurrentVersion);
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3()
|
||||
{
|
||||
// The deployed-gateway scenario: a database already provisioned at the donor's version 2.
|
||||
// Migrating must accept it (2 <= 3), add the expires_utc column, and re-stamp to 3 — no key
|
||||
// is invalidated, the table survives.
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await SetVersionAsync(2);
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None); // must not throw
|
||||
|
||||
Assert.Equal(3, await ReadVersionAsync());
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
|
||||
Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_PreV3ApiKeysTableWithoutExpiresUtc_AddsColumn_AndPreservesRows()
|
||||
{
|
||||
// Simulate a genuine pre-expiry (v2) database: an api_keys table WITHOUT expires_utc, holding
|
||||
// an existing key. The forward migration must add the column (existing row → NULL) without
|
||||
// dropping the row.
|
||||
await CreateLegacyV2SchemaWithKeyAsync("legacy-key");
|
||||
|
||||
Assert.False(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
|
||||
|
||||
await new SqliteAuthStoreMigrator(Factory).MigrateAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
|
||||
Assert.Equal(3, await ReadVersionAsync());
|
||||
|
||||
// The pre-existing key survives and its new expires_utc is NULL (never-expires).
|
||||
var read = new SqliteApiKeyStore(Factory);
|
||||
var found = await read.FindByKeyIdAsync("legacy-key", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Null(found!.ExpiresUtc);
|
||||
}
|
||||
|
||||
private async Task CreateLegacyV2SchemaWithKeyAsync(string keyId)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = $"""
|
||||
CREATE TABLE api_keys (
|
||||
key_id TEXT PRIMARY KEY,
|
||||
key_prefix TEXT NOT NULL,
|
||||
secret_hash BLOB NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
constraints TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
);
|
||||
{SqliteAuthSchema.CreateSchemaVersionTable}
|
||||
INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 2, '2026-01-01T00:00:00.0000000+00:00');
|
||||
INSERT INTO api_keys (key_id, key_prefix, secret_hash, display_name, scopes, created_utc)
|
||||
VALUES ($key_id, 'mxgw', X'01020304', 'Legacy', 'read', '2026-01-01T00:00:00.0000000+00:00');
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<bool> ColumnExistsAsync(string table, string column)
|
||||
{
|
||||
await using SqliteConnection connection =
|
||||
await Factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = $"PRAGMA table_info({table});";
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None);
|
||||
while (await reader.ReadAsync(CancellationToken.None))
|
||||
{
|
||||
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_FutureSchemaVersion_Throws()
|
||||
{
|
||||
|
||||
@@ -99,4 +99,70 @@ public sealed class GLAuthIntegrationTests
|
||||
$"Authentication failed: {result.Failure} (server={server}:{port}, user={user})");
|
||||
Assert.NotEmpty(result.Groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Failover: primary endpoint points at a closed local port (refused immediately),
|
||||
/// fallback at the real GLAuth. The login must succeed via the fallback, and a second
|
||||
/// login must also succeed (sticky preference — no re-walk through the dead primary).
|
||||
/// Skipped unless <c>ZB_LDAP_IT=1</c> is set; skipped again if the server is unreachable.
|
||||
/// </summary>
|
||||
[SkippableFact]
|
||||
public async Task Authenticate_FailsOverFromDeadPrimary_ToRealGLAuth()
|
||||
{
|
||||
// ------------------------------------------------------------------ opt-in gate
|
||||
Skip.IfNot(
|
||||
Environment.GetEnvironmentVariable("ZB_LDAP_IT") == "1",
|
||||
"Set ZB_LDAP_IT=1 and a reachable GLAuth to run.");
|
||||
|
||||
// ------------------------------------------------------------------ read config
|
||||
var server = Environment.GetEnvironmentVariable("ZB_LDAP_SERVER") ?? "localhost";
|
||||
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_PORT"), out var p) ? p : 3893;
|
||||
var baseDn = Environment.GetEnvironmentVariable("ZB_LDAP_BASE") ?? "dc=lmxopcua,dc=local";
|
||||
var svcDn = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_DN") ?? "";
|
||||
var svcPw = Environment.GetEnvironmentVariable("ZB_LDAP_SVC_PW") ?? "";
|
||||
var user = Environment.GetEnvironmentVariable("ZB_LDAP_USER") ?? "";
|
||||
var pw = Environment.GetEnvironmentVariable("ZB_LDAP_PW") ?? "";
|
||||
var userAttr = Environment.GetEnvironmentVariable("ZB_LDAP_USERATTR") ?? "cn";
|
||||
|
||||
// ------------------------------------------------------------------ reachability probe
|
||||
try
|
||||
{
|
||||
using var tcp = new TcpClient();
|
||||
// 3-second connect timeout to keep the test suite snappy when the server is absent
|
||||
var connectTask = tcp.ConnectAsync(server, port);
|
||||
if (!connectTask.Wait(TimeSpan.FromSeconds(3)))
|
||||
Skip.If(true, $"GLAuth not reachable at {server}:{port} (connect timed out).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Skip.If(true, $"GLAuth not reachable at {server}:{port}: {ex.Message}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ build options
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "127.0.0.1",
|
||||
Port = 39999, // closed port -> immediate refusal
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = true,
|
||||
SearchBase = baseDn,
|
||||
ServiceAccountDn = svcDn,
|
||||
ServiceAccountPassword = svcPw,
|
||||
UserNameAttribute = userAttr,
|
||||
GroupAttribute = "memberOf",
|
||||
ConnectionTimeoutMs = 3000,
|
||||
FallbackServers = new[] { $"{server}:{port}" },
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ exercise the real service
|
||||
var svc = new LdapAuthService(options);
|
||||
|
||||
var first = await svc.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
Assert.True(first.Succeeded, $"first login failed: {first.Failure}");
|
||||
|
||||
// Second login exercises the sticky preference: it must not re-walk the dead primary.
|
||||
var second = await svc.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
Assert.True(second.Succeeded, $"second login failed: {second.Failure}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the multi-endpoint failover walk: which failures fail over (system-side only),
|
||||
/// which are final (any authoritative answer from a healthy directory), how the sticky
|
||||
/// endpoint preference moves, and that single-endpoint behaviour is unchanged.
|
||||
/// </summary>
|
||||
public class LdapAuthServiceFailoverTests
|
||||
{
|
||||
private static LdapOptions Opts(params string[] fallbacks) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "dc1",
|
||||
Port = 636,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
SearchBase = "dc=x",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
ServiceAccountPassword = "svcpw",
|
||||
FallbackServers = fallbacks,
|
||||
};
|
||||
|
||||
private static FakeLdapConnection HealthyWithUser() =>
|
||||
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=ops,dc=x" });
|
||||
|
||||
// ---- failover triggers (system-side) ----
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectFailure_FailsOverToFallback_AndSucceeds()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2:3894"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
Assert.Equal("dc1", factory.Created[0].ConnectArgs!.Value.Host);
|
||||
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
||||
Assert.Equal(3894, factory.Created[1].ConnectArgs!.Value.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceBindFailure_FailsOverToFallback()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(636, factory.Created[1].ConnectArgs!.Value.Port); // bare host adopts Port
|
||||
}
|
||||
|
||||
// ---- user-side results NEVER fail over ----
|
||||
|
||||
[Fact]
|
||||
public async Task BadUserCredentials_DoesNotFailOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
HealthyWithUser().ThrowOnUserBind());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserNotFound_DoesNotFailOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.UserNotFound, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupLookupFailed_DoesNotFailOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", Array.Empty<string>()));
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AmbiguousUser_DoesNotFailOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithDuplicateMatch());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.AmbiguousUser, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
// ---- exhaustion ----
|
||||
|
||||
[Fact]
|
||||
public async Task AllEndpointsDown_ReturnsServiceAccountBindFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(),
|
||||
new FakeLdapConnection().ThrowOnConnect());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
}
|
||||
|
||||
// ---- stickiness ----
|
||||
|
||||
[Fact]
|
||||
public async Task StickyPreference_SecondLoginStartsAtLastGoodEndpoint()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
||||
HealthyWithUser(), // login 1 @ dc2: answers
|
||||
HealthyWithUser()); // login 2: should go straight to dc2
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
|
||||
Assert.Equal(3, factory.Created.Count);
|
||||
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StickyPreference_DriftsBackToPrimary_WhenPreferredFails()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
||||
HealthyWithUser(), // login 1 @ dc2: answers -> preferred
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 2 @ dc2: now dead
|
||||
HealthyWithUser(), // login 2 @ dc1 (declared order): recovered
|
||||
HealthyWithUser()); // login 3: should start at dc1 again
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
|
||||
Assert.Equal(5, factory.Created.Count);
|
||||
Assert.Equal("dc1", factory.Created[3].ConnectArgs!.Value.Host);
|
||||
Assert.Equal("dc1", factory.Created[4].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StickyPreference_UpdatesOnUserSideAnswer_NotOnlySuccess()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
||||
new FakeLdapConnection(), // login 1 @ dc2: answers UserNotFound
|
||||
HealthyWithUser()); // login 2: should start at dc2
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
var first = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
||||
Assert.Equal(LdapAuthFailure.UserNotFound, first.Failure);
|
||||
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllEndpointsDown_LeavesPreferenceUnchanged()
|
||||
{
|
||||
// A total outage teaches nothing about which endpoint is healthy, so the walk
|
||||
// must not move the preference — the next login still starts at the primary.
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1
|
||||
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc2
|
||||
HealthyWithUser()); // login 2: must start at dc1 again
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed,
|
||||
(await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Failure);
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
|
||||
Assert.Equal("dc1", factory.Created[2].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
// ---- back-compat ----
|
||||
|
||||
[Fact]
|
||||
public async Task NoFallbacks_SingleAttempt_UnchangedBehavior()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect());
|
||||
var svc = new LdapAuthService(Opts(/* no fallbacks */), factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_NeverOpensAConnection_EvenWithFallbacks()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2") with { Enabled = false }, factory);
|
||||
|
||||
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.Disabled, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedFallbackEntry_IsSkippedDefensively()
|
||||
{
|
||||
// The validator rejects this at boot; a service constructed without validation
|
||||
// must degrade to the parseable subset, not throw.
|
||||
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts(":::"), factory);
|
||||
|
||||
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
// ---- observability ----
|
||||
|
||||
/// <summary>Captures emitted log entries so failover visibility can be asserted.</summary>
|
||||
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<LdapAuthService>
|
||||
{
|
||||
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
||||
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
||||
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failover_LogsWarningWithEndpointAndStage_AndInfoOnStickyChange()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2:3894"), factory, logger);
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
||||
&& e.Message.Contains("dc1:636") && e.Message.Contains("connect"));
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Level == Microsoft.Extensions.Logging.LogLevel.Information
|
||||
&& e.Message.Contains("dc2:3894"));
|
||||
// Never log secrets:
|
||||
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("svcpw") || e.Message.Contains("pw\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HappyPath_LogsNothing()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory, logger);
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Empty(logger.Entries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServiceBindFailure_LogsTheServiceBindStage_NotConnect()
|
||||
{
|
||||
var logger = new RecordingLogger();
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
|
||||
var svc = new LdapAuthService(Opts("dc2"), factory, logger);
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
||||
|
||||
Assert.Contains(logger.Entries, e =>
|
||||
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
||||
&& e.Message.Contains("dc1:636") && e.Message.Contains("service-bind"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
public class LdapEndpointParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("dc2.example.com", 636, "dc2.example.com", 636)] // bare host -> default port
|
||||
[InlineData("dc2.example.com:3894", 636, "dc2.example.com", 3894)] // explicit port wins
|
||||
[InlineData(" dc2.example.com : 3894 ", 636, "dc2.example.com", 3894)] // whitespace tolerated
|
||||
public void TryParse_ValidEntry_Succeeds(string entry, int defaultPort, string host, int port)
|
||||
{
|
||||
Assert.True(LdapEndpointParser.TryParse(entry, defaultPort, out var ep, out _));
|
||||
Assert.Equal(host, ep.Host);
|
||||
Assert.Equal(port, ep.Port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")] // empty
|
||||
[InlineData(" ")] // whitespace
|
||||
[InlineData(":636")] // empty host
|
||||
[InlineData("dc2:0")] // port below range
|
||||
[InlineData("dc2:65536")] // port above range
|
||||
[InlineData("dc2:abc")] // non-numeric port
|
||||
[InlineData("2001:db8::1:636")] // IPv6 literal (unsupported, >1 colon)
|
||||
public void TryParse_InvalidEntry_Fails(string entry)
|
||||
{
|
||||
Assert.False(LdapEndpointParser.TryParse(entry, 636, out _, out var error));
|
||||
Assert.NotEmpty(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Endpoint_ToString_IsHostColonPort() =>
|
||||
Assert.Equal("dc2:3894", new LdapEndpoint("dc2", 3894).ToString());
|
||||
}
|
||||
@@ -72,4 +72,48 @@ public class LdapOptionsValidatorTests
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts())
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Skips_AllChecks_WhenDisabled() =>
|
||||
// When LDAP is disabled its connection fields are inert; an otherwise-invalid
|
||||
// config (plaintext + blank Server/SearchBase/ServiceAccountDn) must still pass.
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Transport = LdapTransport.None,
|
||||
AllowInsecure = false,
|
||||
Server = "",
|
||||
SearchBase = "",
|
||||
ServiceAccountDn = "",
|
||||
})
|
||||
.Failed);
|
||||
|
||||
[Fact]
|
||||
public void Validator_Succeeds_WithValidFallbackServers() =>
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { FallbackServers = new[] { "dc2", "dc3:3894" } })
|
||||
.Failed);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(":636")]
|
||||
[InlineData("dc2:0")]
|
||||
[InlineData("dc2:notaport")]
|
||||
public void Validator_Fails_OnMalformedFallbackServer(string entry)
|
||||
{
|
||||
var result = new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { FallbackServers = new[] { entry } });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.FallbackServers), result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_Skips_FallbackServers_WhenDisabled() =>
|
||||
// Same inert-when-disabled rule as every other connection field: a disabled
|
||||
// provider never opens a connection, so its fallback list is never walked.
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
|
||||
.Failed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using Novell.Directory.Ldap;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="NovellLdapConnection.ReadEntries"/>' handling of AD continuation
|
||||
/// referrals. Active Directory returns one for any search based at a domain root, and
|
||||
/// Novell surfaces it by throwing from <c>Next()</c>. Before this was handled, the
|
||||
/// exception escaped the search stage and every login against
|
||||
/// <c>DC=nam,DC=zimmer,DC=com</c> failed as <c>ServiceAccountBindFailed</c> while the
|
||||
/// directory and credentials were healthy.
|
||||
/// </summary>
|
||||
public sealed class NovellSearchReferralTests
|
||||
{
|
||||
[Fact]
|
||||
public void Referral_before_the_only_entry_does_not_lose_it()
|
||||
{
|
||||
var results = new ScriptedResults(
|
||||
Step.Referral(),
|
||||
Step.Entry("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com"));
|
||||
|
||||
var entries = NovellLdapConnection.ReadEntries(results);
|
||||
|
||||
var entry = Assert.Single(entries);
|
||||
Assert.Equal("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com", entry.Dn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Referral_after_the_entry_does_not_discard_what_was_already_read()
|
||||
{
|
||||
var results = new ScriptedResults(
|
||||
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
|
||||
Step.Referral());
|
||||
|
||||
var entries = NovellLdapConnection.ReadEntries(results);
|
||||
|
||||
Assert.Equal("CN=a,DC=nam,DC=zimmer,DC=com", Assert.Single(entries).Dn);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The real shape observed against the NAM domain root: several references interleaved
|
||||
/// with the one user entry. All three of AD's usual continuation points are present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Multiple_interleaved_referrals_still_yield_every_entry()
|
||||
{
|
||||
var results = new ScriptedResults(
|
||||
Step.Referral(),
|
||||
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
|
||||
Step.Referral(),
|
||||
Step.Referral(),
|
||||
Step.Entry("CN=b,DC=nam,DC=zimmer,DC=com"));
|
||||
|
||||
var entries = NovellLdapConnection.ReadEntries(results);
|
||||
|
||||
Assert.Equal(["CN=a,DC=nam,DC=zimmer,DC=com", "CN=b,DC=nam,DC=zimmer,DC=com"],
|
||||
entries.Select(e => e.Dn));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A referral-only result set is a legitimate "nothing here" answer, and must come back
|
||||
/// empty rather than throwing — <c>LdapAuthService</c> reads empty as <c>UserNotFound</c>,
|
||||
/// which is the honest verdict, where an exception would become a system-side failure.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Referrals_only_returns_empty_rather_than_throwing()
|
||||
{
|
||||
var results = new ScriptedResults(Step.Referral(), Step.Referral());
|
||||
|
||||
Assert.Empty(NovellLdapConnection.ReadEntries(results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A base <see cref="LdapException"/> carrying the Referral result code is treated the
|
||||
/// same as the derived type — the catch filter must not depend on the exact class.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Referral_result_code_on_a_base_exception_is_also_skipped()
|
||||
{
|
||||
var results = new ScriptedResults(
|
||||
Step.Throwing(new LdapException("referral", LdapException.Referral, null)),
|
||||
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
|
||||
|
||||
Assert.Single(NovellLdapConnection.ReadEntries(results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only referrals are swallowed. A genuine directory fault must still propagate so the
|
||||
/// caller maps it to a system-side failure — silently returning a short result set would
|
||||
/// turn "the directory is broken" into "no such user", which is the wrong answer to give
|
||||
/// about someone's credentials.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_non_referral_fault_still_propagates()
|
||||
{
|
||||
var results = new ScriptedResults(
|
||||
Step.Throwing(new LdapException("busy", LdapException.Busy, null)),
|
||||
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
|
||||
|
||||
var ex = Assert.Throws<LdapException>(() => NovellLdapConnection.ReadEntries(results));
|
||||
Assert.Equal(LdapException.Busy, ex.ResultCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pathological client that throws a referral without advancing must not spin forever
|
||||
/// inside a fail-closed authentication path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_endless_referral_stream_terminates()
|
||||
{
|
||||
var results = new EndlessReferrals();
|
||||
|
||||
var entries = NovellLdapConnection.ReadEntries(results);
|
||||
|
||||
Assert.Empty(entries);
|
||||
Assert.InRange(results.Calls, 1, 2_000);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private sealed record Step(LdapEntry? Value, Exception? Throw)
|
||||
{
|
||||
public static Step Entry(string dn) => new(new LdapEntry(dn, new LdapAttributeSet()), null);
|
||||
public static Step Referral() => new(null, new LdapReferralException("continuation reference"));
|
||||
public static Step Throwing(Exception ex) => new(null, ex);
|
||||
}
|
||||
|
||||
/// <summary>Replays a fixed script of entries and faults through the Novell result seam.</summary>
|
||||
private sealed class ScriptedResults : ILdapSearchResults
|
||||
{
|
||||
private readonly Step[] _steps;
|
||||
private int _index;
|
||||
|
||||
public ScriptedResults(params Step[] steps) => _steps = steps;
|
||||
|
||||
public int Count => _steps.Length;
|
||||
|
||||
public LdapControl[] ResponseControls => [];
|
||||
|
||||
public bool HasMore() => _index < _steps.Length;
|
||||
|
||||
public LdapEntry Next()
|
||||
{
|
||||
var step = _steps[_index++];
|
||||
if (step.Throw is not null) throw step.Throw;
|
||||
return step.Value!;
|
||||
}
|
||||
|
||||
// The production reader drives HasMore()/Next() directly; enumeration is part of the
|
||||
// interface but not part of the path under test.
|
||||
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
|
||||
/// <summary>Always claims more results and always throws a referral, never advancing.</summary>
|
||||
private sealed class EndlessReferrals : ILdapSearchResults
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public int Count => int.MaxValue;
|
||||
|
||||
public LdapControl[] ResponseControls => [];
|
||||
|
||||
public bool HasMore() => true;
|
||||
|
||||
public LdapEntry Next()
|
||||
{
|
||||
Calls++;
|
||||
throw new LdapReferralException("continuation reference");
|
||||
}
|
||||
|
||||
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
|
||||
|
||||
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for <see cref="ILdapConnectionFactory"/> that hands out pre-scripted
|
||||
/// <see cref="FakeLdapConnection"/> instances in order, one per <see cref="Create"/> call —
|
||||
/// exactly matching the failover walk, which opens a fresh connection per endpoint attempt.
|
||||
/// Records every created connection for inspection. When the script runs out, returns a
|
||||
/// bare unscripted fake (which yields user-not-found) so a miscounted test fails loudly
|
||||
/// rather than reusing a stale fake.
|
||||
/// </summary>
|
||||
internal sealed class SequenceLdapConnectionFactory : ILdapConnectionFactory
|
||||
{
|
||||
private readonly Queue<FakeLdapConnection> _script;
|
||||
|
||||
/// <summary>Scripts the connections handed out, in the order the walk will request them.</summary>
|
||||
public SequenceLdapConnectionFactory(params FakeLdapConnection[] fakes)
|
||||
=> _script = new Queue<FakeLdapConnection>(fakes);
|
||||
|
||||
/// <summary>Every connection handed out, in creation order (scripted or overflow).</summary>
|
||||
public List<FakeLdapConnection> Created { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public ILdapConnection Create()
|
||||
{
|
||||
var fake = _script.Count > 0 ? _script.Dequeue() : new FakeLdapConnection();
|
||||
Created.Add(fake);
|
||||
return fake;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smoke test: verifies the sequencing and overflow behaviour the failover tests rely on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public class SequenceLdapConnectionFactorySmokeTests
|
||||
{
|
||||
[Fact]
|
||||
public void Create_ReturnsScriptedFakesInOrder_ThenOverflows()
|
||||
{
|
||||
var first = new FakeLdapConnection().ThrowOnConnect();
|
||||
var second = new FakeLdapConnection();
|
||||
var factory = new SequenceLdapConnectionFactory(first, second);
|
||||
|
||||
Assert.Same(first, factory.Create());
|
||||
Assert.Same(second, factory.Create());
|
||||
Assert.NotSame(second, factory.Create()); // overflow: fresh bare fake
|
||||
Assert.Equal(3, factory.Created.Count);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
|
||||
|
||||
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
|
||||
|
||||
**Built at 0.1.0. Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge.** Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
|
||||
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
|
||||
|
||||
## Status
|
||||
|
||||
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Not yet adopted by OtOpcUa, MxAccessGateway, or ScadaBridge** — follow-on adoption is tracked in:
|
||||
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ No third-party packages; no ASP.NET Core framework reference.
|
||||
|
||||
## Status
|
||||
|
||||
**Built at 0.1.0. Not yet adopted by the three apps.** Adoption is tracked in the component backlog:
|
||||
**Built at 0.1.0. Adopted across all three apps on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption is tracked in the component backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.2.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Library -->
|
||||
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<!-- Google.Protobuf and Grpc.Tools must be >= the minimums Grpc.AspNetCore 2.76.0 requires -->
|
||||
<PackageVersion Include="Google.Protobuf" Version="3.31.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||
|
||||
<!-- Test -->
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.GalaxyRepository/ZB.MOM.WW.GalaxyRepository.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.GalaxyRepository.Tests/ZB.MOM.WW.GalaxyRepository.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency-injection and endpoint-routing extensions that register the reusable
|
||||
/// Galaxy Repository services and map the canonical gRPC service. A consuming gateway
|
||||
/// calls <see cref="AddZbGalaxyRepository"/> during service registration and
|
||||
/// <see cref="MapZbGalaxyRepository"/> while building its endpoint pipeline.
|
||||
/// </summary>
|
||||
public static class GalaxyRepositoryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the Galaxy Repository SQL provider, shared hierarchy cache, deploy
|
||||
/// notifier, on-disk snapshot store, and the background refresh service, binding
|
||||
/// <see cref="GalaxyRepositoryOptions"/> from the supplied configuration section.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add registrations to.</param>
|
||||
/// <param name="configuration">The application configuration root.</param>
|
||||
/// <param name="sectionPath">
|
||||
/// The configuration section path to bind <see cref="GalaxyRepositoryOptions"/> from
|
||||
/// (for example <c>MxGateway:Galaxy</c> or <c>HistorianGateway:Galaxy</c>).
|
||||
/// </param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddZbGalaxyRepository(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
string sectionPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
// Bind only — this shared lib ships no validator, so a .ValidateOnStart() here
|
||||
// would be a silent no-op. The consuming application owns option validation
|
||||
// (e.g. the sidecar's ConfigPreflight / validated-options layer).
|
||||
services
|
||||
.AddOptions<GalaxyRepositoryOptions>()
|
||||
.Bind(configuration.GetSection(sectionPath));
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
|
||||
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
||||
|
||||
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
||||
services.AddSingleton<IGalaxyHierarchySnapshotStore, GalaxyHierarchySnapshotStore>();
|
||||
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
||||
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
||||
|
||||
// Allow the hosting gateway to override with its own scoped implementation.
|
||||
services.TryAddSingleton<IGalaxyBrowseScopeProvider, NullGalaxyBrowseScopeProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the canonical <see cref="GalaxyRepositoryGrpcService"/> onto the consuming
|
||||
/// application's endpoint pipeline. Call after <see cref="AddZbGalaxyRepository"/> and
|
||||
/// after gRPC has been added to the application's services.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder to map the gRPC service onto.</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapZbGalaxyRepository(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// One alarm-bearing attribute discovered by <c>GalaxyRepository.GetAlarmAttributesAsync</c>:
|
||||
/// an attribute whose owning object configures an <c>AlarmExtension</c> primitive (the
|
||||
/// same <c>is_alarm</c> detection used by
|
||||
/// <see cref="GalaxyRepository.GetAttributesAsync"/>).
|
||||
/// Used to build the subtag-fallback watch-list.
|
||||
/// </summary>
|
||||
public sealed class GalaxyAlarmAttributeRow
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the alarm-bearing attribute reference (e.g. <c>Tank01.Level.HiHi</c>),
|
||||
/// matching the <c>full_tag_reference</c> projection of
|
||||
/// <see cref="GalaxyRepository.GetAttributesAsync"/>.
|
||||
/// </summary>
|
||||
public string FullTagReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owning object reference (e.g. <c>Tank01</c>). This is the Galaxy
|
||||
/// <c>tag_name</c> — the segment that precedes the first attribute dot in
|
||||
/// <see cref="FullTagReference"/>.
|
||||
/// </summary>
|
||||
public string SourceObjectReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owning object's Galaxy area (e.g. <c>TestArea</c>) — the alarm group.
|
||||
/// <para>
|
||||
/// Resolved via <c>gobject.area_gobject_id</c> in <c>AlarmAttributesSql</c>. The
|
||||
/// watch-list resolver composes the canonical <c>Galaxy!{area}.{reference}</c> from
|
||||
/// this so the synthesized reference's group matches the native alarmmgr (wnwrap)
|
||||
/// for reference parity. May be <see cref="string.Empty"/> when the object has no
|
||||
/// area; the resolver then falls back to the configured area.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string Area { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the writable ack-comment attribute address.
|
||||
/// <para>
|
||||
/// The Galaxy Repository schema does not expose an ack-comment subtag address
|
||||
/// directly, so this is always <see cref="string.Empty"/> here. The watch-list
|
||||
/// resolver (a later task) composes the concrete address from configuration plus
|
||||
/// <see cref="SourceObjectReference"/> / <see cref="FullTagReference"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string AckCommentSubtag { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
|
||||
public sealed class GalaxyAttributeRow
|
||||
{
|
||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||
public int GobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the tag name.</summary>
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the attribute name.</summary>
|
||||
public string AttributeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the full tag reference.</summary>
|
||||
public string FullTagReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the MXAccess data type code.</summary>
|
||||
public int MxDataType { get; init; }
|
||||
|
||||
/// <summary>Gets the data type name.</summary>
|
||||
public string? DataTypeName { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an array.</summary>
|
||||
public bool IsArray { get; init; }
|
||||
|
||||
/// <summary>Gets the array dimension, if applicable.</summary>
|
||||
public int? ArrayDimension { get; init; }
|
||||
|
||||
/// <summary>Gets the MXAccess attribute category code.</summary>
|
||||
public int MxAttributeCategory { get; init; }
|
||||
|
||||
/// <summary>Gets the security classification code.</summary>
|
||||
public int SecurityClassification { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is historized.</summary>
|
||||
public bool IsHistorized { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an alarm.</summary>
|
||||
public bool IsAlarm { get; init; }
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Result of one <see cref="GalaxyBrowseProjector.ProjectChildren"/> call. Holds a
|
||||
/// materialized page of direct children for the requested parent, along with a
|
||||
/// parallel-indexed <see cref="ChildHasChildren"/> hint and the total post-filter
|
||||
/// sibling count for paging.
|
||||
/// </summary>
|
||||
/// <param name="Children">The page of direct children, sorted areas-first then by display name.</param>
|
||||
/// <param name="ChildHasChildren">Parallel array indicating whether each child has at least one matching descendant under the same filter set.</param>
|
||||
/// <param name="TotalChildCount">Total matching direct children of the parent (post-filter).</param>
|
||||
/// <param name="FilterSignature">Stable signature of the filter and parent selector, used to bind page tokens.</param>
|
||||
public sealed record GalaxyBrowseChildrenResult(
|
||||
IReadOnlyList<GalaxyObject> Children,
|
||||
IReadOnlyList<bool> ChildHasChildren,
|
||||
int TotalChildCount,
|
||||
string FilterSignature);
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Projects one level of children of a parent object out of an immutable
|
||||
/// <see cref="GalaxyHierarchyCacheEntry"/>. Pure and side-effect free. Memoizes the
|
||||
/// filtered child list per cache-entry instance so repeated paging is an O(pageSize)
|
||||
/// slice rather than an O(siblings) filter scan per page. The memo is keyed on the
|
||||
/// immutable cache entry, so when the cache publishes a new entry the stale memo
|
||||
/// becomes unreachable and is reclaimed with it.
|
||||
/// </summary>
|
||||
public static class GalaxyBrowseProjector
|
||||
{
|
||||
private static readonly ConditionalWeakTable<
|
||||
GalaxyHierarchyCacheEntry,
|
||||
ConcurrentDictionary<string, FilteredChildren>> FilteredChildrenCache = new();
|
||||
|
||||
/// <summary>Projects one page of direct children of the resolved parent.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
||||
/// <param name="pageSize">Maximum number of children to return.</param>
|
||||
public static GalaxyBrowseChildrenResult ProjectChildren(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int offset,
|
||||
int pageSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||
}
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||
}
|
||||
|
||||
int parentId = ResolveParentId(entry, request);
|
||||
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs, parentId);
|
||||
FilteredChildren filtered = GetFilteredChildren(entry, request, browseSubtreeGlobs, parentId, filterSignature);
|
||||
|
||||
bool includeAttributes = IncludeAttributes(request);
|
||||
int end = (int)Math.Min((long)offset + pageSize, filtered.Children.Count);
|
||||
List<GalaxyObject> page = new(Math.Max(0, end - offset));
|
||||
List<bool> hasChildren = new(Math.Max(0, end - offset));
|
||||
for (int index = offset; index < end; index++)
|
||||
{
|
||||
page.Add(CloneObject(filtered.Children[index].Object, includeAttributes));
|
||||
hasChildren.Add(filtered.HasMatchingDescendant[index]);
|
||||
}
|
||||
|
||||
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the request's parent oneof to a gobject id, throwing
|
||||
/// <see cref="RpcException"/> with <see cref="StatusCode.NotFound"/> when the
|
||||
/// parent does not exist. Public so the gRPC handler can compute the same
|
||||
/// parent id (needed for the page-token signature) without reimplementing the
|
||||
/// resolution rules.
|
||||
/// </summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
{
|
||||
switch (request.ParentCase)
|
||||
{
|
||||
case BrowseChildrenRequest.ParentOneofCase.None:
|
||||
return 0;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentGobjectId:
|
||||
if (request.ParentGobjectId == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return request.ParentGobjectId;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
||||
{
|
||||
if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return match.Object.GobjectId;
|
||||
}
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
||||
{
|
||||
if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
return match.Object.GobjectId;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static FilteredChildren GetFilteredChildren(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int parentId,
|
||||
string filterSignature)
|
||||
{
|
||||
ConcurrentDictionary<string, FilteredChildren> memo =
|
||||
FilteredChildrenCache.GetValue(entry, static _ => new ConcurrentDictionary<string, FilteredChildren>(StringComparer.Ordinal));
|
||||
|
||||
return memo.GetOrAdd(
|
||||
filterSignature,
|
||||
static (_, state) =>
|
||||
{
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> map = state.Entry.Index.ChildrenByParent;
|
||||
IReadOnlyList<GalaxyObjectView> directChildren = map.TryGetValue(state.ParentId, out IReadOnlyList<GalaxyObjectView>? list)
|
||||
? list
|
||||
: Array.Empty<GalaxyObjectView>();
|
||||
|
||||
List<GalaxyObjectView> matched = [];
|
||||
List<bool> hasMatching = [];
|
||||
foreach (GalaxyObjectView view in directChildren)
|
||||
{
|
||||
if (!MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!MatchesFilters(view.Object, state.Request))
|
||||
{
|
||||
// Even if the direct child itself fails the filter, a matching
|
||||
// descendant should still surface its ancestor — but only when
|
||||
// there is one. Mirror the dashboard browse-tree semantics: if a
|
||||
// descendant matches, include the parent with has-children true.
|
||||
if (HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs))
|
||||
{
|
||||
matched.Add(view);
|
||||
hasMatching.Add(true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
matched.Add(view);
|
||||
hasMatching.Add(HasMatchingDescendant(view, state.Entry.Index, state.Request, state.BrowseSubtreeGlobs));
|
||||
}
|
||||
|
||||
return new FilteredChildren(matched, hasMatching);
|
||||
},
|
||||
(Entry: entry, ParentId: parentId, Request: request, BrowseSubtreeGlobs: browseSubtreeGlobs));
|
||||
}
|
||||
|
||||
private static bool HasMatchingDescendant(
|
||||
GalaxyObjectView parent,
|
||||
GalaxyHierarchyIndex index,
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
if (!index.ChildrenByParent.TryGetValue(parent.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? children))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain).
|
||||
// BuildContainedPath uses the same visited-id pattern; mirror it so this walk
|
||||
// terminates even when ChildrenByParent forms a cycle.
|
||||
HashSet<int> visited = new() { parent.Object.GobjectId };
|
||||
Stack<GalaxyObjectView> stack = new();
|
||||
foreach (GalaxyObjectView child in children)
|
||||
{
|
||||
if (visited.Add(child.Object.GobjectId))
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
GalaxyObjectView candidate = stack.Pop();
|
||||
if (MatchesBrowseSubtrees(candidate, browseSubtreeGlobs)
|
||||
&& MatchesFilters(candidate.Object, request))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (index.ChildrenByParent.TryGetValue(candidate.Object.GobjectId, out IReadOnlyList<GalaxyObjectView>? grandchildren))
|
||||
{
|
||||
foreach (GalaxyObjectView grandchild in grandchildren)
|
||||
{
|
||||
if (visited.Add(grandchild.Object.GobjectId))
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool MatchesBrowseSubtrees(GalaxyObjectView view, IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
return browseSubtreeGlobs is null
|
||||
|| browseSubtreeGlobs.Count == 0
|
||||
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(GalaxyObject obj, BrowseChildrenRequest request)
|
||||
{
|
||||
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
foreach (string templateFilter in request.TemplateChainContains)
|
||||
{
|
||||
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IncludeAttributes(BrowseChildrenRequest request)
|
||||
{
|
||||
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||
}
|
||||
|
||||
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||
{
|
||||
GalaxyObject clone = source.Clone();
|
||||
if (!includeAttributes)
|
||||
{
|
||||
clone.Attributes.Clear();
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
||||
public static string ComputeFilterSignature(
|
||||
BrowseChildrenRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int parentId)
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
builder.Append("parent=").Append(parentId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash, 0, 12);
|
||||
}
|
||||
|
||||
private sealed record FilteredChildren(
|
||||
IReadOnlyList<GalaxyObjectView> Children,
|
||||
IReadOnlyList<bool> HasMatchingDescendant);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>Freshness state of the shared Galaxy hierarchy cache entry.</summary>
|
||||
public enum GalaxyCacheStatus
|
||||
{
|
||||
/// <summary>Cache has never completed a refresh.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Cache holds data from a recent successful refresh.</summary>
|
||||
Healthy = 1,
|
||||
|
||||
/// <summary>Cache holds data, but the most recent refresh attempt failed
|
||||
/// or no successful refresh has happened within the staleness threshold.</summary>
|
||||
Stale = 2,
|
||||
|
||||
/// <summary>Latest refresh failed and no prior data is available.</summary>
|
||||
Unavailable = 3,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
|
||||
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
|
||||
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
|
||||
/// subscribers (the streaming gRPC RPC).
|
||||
/// </summary>
|
||||
/// <param name="Sequence">Monotonically increasing per process start; gaps indicate dropped events.</param>
|
||||
/// <param name="ObservedAt">Server wall-clock when the cache observed the deploy.</param>
|
||||
/// <param name="TimeOfLastDeploy">The <c>galaxy.time_of_last_deploy</c> value, or <see langword="null"/> when the Galaxy table reports none.</param>
|
||||
/// <param name="ObjectCount">Number of objects in the hierarchy at the time of the event.</param>
|
||||
/// <param name="AttributeCount">Number of attributes in the hierarchy at the time of the event.</param>
|
||||
public sealed record GalaxyDeployEventInfo(
|
||||
long Sequence,
|
||||
DateTimeOffset ObservedAt,
|
||||
DateTimeOffset? TimeOfLastDeploy,
|
||||
int ObjectCount,
|
||||
int AttributeCount);
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each
|
||||
/// subscriber gets a private bounded channel so a slow client cannot back-pressure
|
||||
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
|
||||
/// event is dropped — clients use the sequence field to detect gaps.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
||||
{
|
||||
private const int SubscriberQueueCapacity = 16;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
||||
private GalaxyDeployEventInfo? _latest;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent deploy event, or null if none has been published.
|
||||
/// </summary>
|
||||
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(GalaxyDeployEventInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
Volatile.Write(ref _latest, info);
|
||||
|
||||
foreach (Channel<GalaxyDeployEventInfo> channel in _subscribers.Values)
|
||||
{
|
||||
// BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the
|
||||
// channel was completed by the subscriber side, which we ignore.
|
||||
channel.Writer.TryWrite(info);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
Guid subscriberId = Guid.NewGuid();
|
||||
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
|
||||
new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
_subscribers[subscriberId] = channel;
|
||||
|
||||
// Bootstrap: emit the latest known event so subscribers don't need to wait for
|
||||
// the next deploy to know current state.
|
||||
GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest);
|
||||
if (bootstrap is not null)
|
||||
{
|
||||
channel.Writer.TryWrite(bootstrap);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
|
||||
{
|
||||
yield return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscribers.TryRemove(subscriberId, out _);
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Anchored, case-insensitive glob matcher (<c>*</c> and <c>?</c> wildcards) used by the
|
||||
/// hierarchy and browse projectors to filter object tag names and browse subtrees.
|
||||
/// Compiled regexes are cached and the cache is bounded so an unbounded stream of distinct
|
||||
/// client-supplied globs cannot grow memory without limit.
|
||||
/// </summary>
|
||||
public static class GalaxyGlobMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of compiled-regex entries retained in <see cref="RegexCache"/>.
|
||||
/// The cache is keyed by glob pattern and patterns flow in from two sources:
|
||||
/// admin-controlled API-key constraints (naturally bounded) and the
|
||||
/// client-supplied <c>DiscoverHierarchyRequest.TagNameGlob</c> (unbounded — a
|
||||
/// client can iterate through generated names and create millions of distinct
|
||||
/// globs over the process lifetime). Capping the cache bounds memory while
|
||||
/// keeping the hot working set hit-cached.
|
||||
/// </summary>
|
||||
internal const int RegexCacheCapacity = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called
|
||||
/// once per object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c>
|
||||
/// evaluation, so the same handful of glob patterns are translated
|
||||
/// repeatedly; caching avoids rebuilding and recompiling the regex on every
|
||||
/// call. Beyond <see cref="RegexCacheCapacity"/> entries the oldest insertion
|
||||
/// is evicted so a client cannot grow the cache without bound by submitting
|
||||
/// unique patterns. Eviction is approximate (FIFO over insertion order, not
|
||||
/// true LRU) because we only need the bound, not exact recency tracking.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Insertion-order queue used to evict the oldest cache entry when the cache
|
||||
/// exceeds <see cref="RegexCacheCapacity"/>. A separate queue keeps the
|
||||
/// <see cref="RegexCache"/> reads lock-free; the lock below only guards the
|
||||
/// eviction path.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentQueue<string> InsertionOrder = new();
|
||||
private static readonly object EvictionLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current cache size, exposed for tests asserting the cap is honoured.
|
||||
/// </summary>
|
||||
internal static int CurrentCacheSize => RegexCache.Count;
|
||||
|
||||
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
||||
/// <param name="value">The value to test against the glob pattern.</param>
|
||||
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
||||
public static bool IsMatch(string value, string glob)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(glob))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty);
|
||||
}
|
||||
|
||||
private static Regex GetOrCreateRegex(string glob)
|
||||
{
|
||||
if (RegexCache.TryGetValue(glob, out Regex? existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
Regex compiled = new(
|
||||
BuildRegex(glob),
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// GetOrAdd atomically returns whichever instance is in the cache after the
|
||||
// call — either the locally-compiled regex (we won the race) or the regex
|
||||
// another thread inserted (we lost). It also avoids the TryAdd-then-indexer
|
||||
// pattern where the key could be evicted between the failed TryAdd and the
|
||||
// indexer read, producing a KeyNotFoundException under contention near the cap.
|
||||
Regex result = RegexCache.GetOrAdd(glob, compiled);
|
||||
if (ReferenceEquals(result, compiled))
|
||||
{
|
||||
// We were the inserter — track for FIFO eviction and bound the cache.
|
||||
InsertionOrder.Enqueue(glob);
|
||||
EvictIfOverCapacity();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EvictIfOverCapacity()
|
||||
{
|
||||
if (RegexCache.Count <= RegexCacheCapacity)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize eviction so two threads do not race past the cap together.
|
||||
lock (EvictionLock)
|
||||
{
|
||||
while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest))
|
||||
{
|
||||
RegexCache.TryRemove(oldest, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRegex(string glob)
|
||||
{
|
||||
StringBuilder builder = new("^", glob.Length + 2);
|
||||
foreach (char character in glob)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '*':
|
||||
builder.Append(".*");
|
||||
break;
|
||||
case '?':
|
||||
builder.Append('.');
|
||||
break;
|
||||
default:
|
||||
builder.Append(Regex.Escape(character.ToString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('$');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
|
||||
/// entry — the materialized object list is produced once per refresh and reused across
|
||||
/// requests. Refreshes are deploy-time gated: every tick queries
|
||||
/// <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy + attributes rowsets
|
||||
/// are pulled only when that timestamp has advanced.
|
||||
/// Each successful heavy refresh is persisted to disk through
|
||||
/// <see cref="IGalaxyHierarchySnapshotStore"/>; the first refresh restores that
|
||||
/// snapshot (as <see cref="GalaxyCacheStatus.Stale"/>) so clients can browse
|
||||
/// last-known data when the Galaxy database is unreachable on a cold start.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IGalaxyRepository _repository;
|
||||
private readonly IGalaxyDeployNotifier _notifier;
|
||||
private readonly IGalaxyHierarchySnapshotStore? _snapshotStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
||||
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
||||
private bool _restoreAttempted;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
||||
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
||||
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <param name="snapshotStore">
|
||||
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
||||
/// successful refresh and restores the last snapshot on first load.
|
||||
/// </param>
|
||||
public GalaxyHierarchyCache(
|
||||
IGalaxyRepository repository,
|
||||
IGalaxyDeployNotifier notifier,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<GalaxyHierarchyCache>? logger = null,
|
||||
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_notifier = notifier;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger;
|
||||
_snapshotStore = snapshotStore;
|
||||
}
|
||||
|
||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current
|
||||
{
|
||||
get
|
||||
{
|
||||
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||
return projected == snapshot.Status
|
||||
? snapshot
|
||||
: snapshot with { Status = projected };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task representing the wait operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _firstLoad.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the refresh gate. As a DI singleton the cache is disposed once at host
|
||||
/// shutdown, after the refresh <see cref="GalaxyHierarchyRefreshService"/> has stopped,
|
||||
/// so no in-flight refresh can be holding the gate.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_refreshGate.Dispose();
|
||||
}
|
||||
|
||||
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// First refresh only: seed the cache from the on-disk snapshot before
|
||||
// querying SQL, so a cold start with an unreachable Galaxy database can
|
||||
// still serve last-known browse data. Runs under the refresh gate.
|
||||
if (!_restoreAttempted)
|
||||
{
|
||||
_restoreAttempted = true;
|
||||
await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
||||
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
|
||||
DateTimeOffset? deployTime = deployRaw.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
|
||||
: null;
|
||||
|
||||
bool hasPriorData = previous.HasData;
|
||||
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
|
||||
|
||||
if (!deployChanged)
|
||||
{
|
||||
// No deploy change — skip heavy queries; just bump LastSuccessAt.
|
||||
GalaxyHierarchyCacheEntry refreshed = previous with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastError = null,
|
||||
};
|
||||
Volatile.Write(ref _current, refreshed);
|
||||
_firstLoad.TrySetResult();
|
||||
return;
|
||||
}
|
||||
|
||||
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
|
||||
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
|
||||
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
|
||||
|
||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||
|
||||
long nextSequence = previous.Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry next = BuildEntry(
|
||||
status: GalaxyCacheStatus.Healthy,
|
||||
sequence: nextSequence,
|
||||
lastQueriedAt: queriedAt,
|
||||
lastSuccessAt: queriedAt,
|
||||
lastDeployTime: deployTime,
|
||||
lastError: null,
|
||||
hierarchy: hierarchy,
|
||||
attributes: attributes);
|
||||
|
||||
Volatile.Write(ref _current, next);
|
||||
_firstLoad.TrySetResult();
|
||||
|
||||
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||
Sequence: nextSequence,
|
||||
ObservedAt: queriedAt,
|
||||
TimeOfLastDeploy: deployTime,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AttributeCount: attributes.Count));
|
||||
|
||||
await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Catch every non-cancellation failure — not just SqlException /
|
||||
// InvalidOperationException. A TimeoutException or Win32Exception
|
||||
// from connection establishment, or another DbException subtype,
|
||||
// must still degrade gracefully to Stale/Unavailable and complete
|
||||
// _firstLoad rather than escape and fault the refresh BackgroundService.
|
||||
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
|
||||
GalaxyHierarchyCacheEntry failed = previous with
|
||||
{
|
||||
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
};
|
||||
Volatile.Write(ref _current, failed);
|
||||
_firstLoad.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes a complete <see cref="GalaxyHierarchyCacheEntry"/> from raw
|
||||
/// hierarchy and attribute rowsets. Shared by the live refresh path and the
|
||||
/// on-disk restore path so both produce an identical object list and index.
|
||||
/// </summary>
|
||||
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||
GalaxyCacheStatus status,
|
||||
long sequence,
|
||||
DateTimeOffset? lastQueriedAt,
|
||||
DateTimeOffset? lastSuccessAt,
|
||||
DateTimeOffset? lastDeployTime,
|
||||
string? lastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||
|
||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||
int historized = attributes.Count(row => row.IsHistorized);
|
||||
int alarms = attributes.Count(row => row.IsAlarm);
|
||||
|
||||
return new GalaxyHierarchyCacheEntry(
|
||||
Status: status,
|
||||
Sequence: sequence,
|
||||
LastQueriedAt: lastQueriedAt,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastDeployTime: lastDeployTime,
|
||||
LastError: lastError,
|
||||
Objects: objects,
|
||||
Index: index,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributes.Count,
|
||||
HistorizedAttributeCount: historized,
|
||||
AlarmAttributeCount: alarms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the cache from the on-disk snapshot when no live data has loaded yet.
|
||||
/// The restored entry is marked <see cref="GalaxyCacheStatus.Stale"/> — it is
|
||||
/// last-known data, not live. A later refresh that observes the same deploy
|
||||
/// time promotes it to healthy; one that observes a newer deploy replaces it.
|
||||
/// </summary>
|
||||
private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_snapshotStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _current).HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GalaxyHierarchySnapshot? snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long sequence = Volatile.Read(ref _current).Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry restored = BuildEntry(
|
||||
status: GalaxyCacheStatus.Stale,
|
||||
sequence: sequence,
|
||||
lastQueriedAt: snapshot.SavedAt,
|
||||
lastSuccessAt: snapshot.SavedAt,
|
||||
lastDeployTime: snapshot.LastDeployTime,
|
||||
lastError: null,
|
||||
hierarchy: snapshot.Hierarchy,
|
||||
attributes: snapshot.Attributes);
|
||||
Volatile.Write(ref _current, restored);
|
||||
|
||||
// Restored data is a valid completed first load: unblock callers waiting on
|
||||
// the bootstrap gate immediately, rather than making them wait out the full
|
||||
// wait budget for a live query that — when the database is unreachable, the
|
||||
// scenario this restore exists for — may not return for seconds.
|
||||
_firstLoad.TrySetResult();
|
||||
|
||||
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||
Sequence: sequence,
|
||||
ObservedAt: _timeProvider.GetUtcNow(),
|
||||
TimeOfLastDeploy: snapshot.LastDeployTime,
|
||||
ObjectCount: snapshot.Hierarchy.Count,
|
||||
AttributeCount: snapshot.Attributes.Count));
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).",
|
||||
snapshot.SavedAt,
|
||||
snapshot.Hierarchy.Count,
|
||||
snapshot.Attributes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a successful refresh to disk. Persistence failures are logged and
|
||||
/// swallowed — a cache that cannot write its backup is still fully usable.
|
||||
/// </summary>
|
||||
private async Task PersistSnapshotAsync(
|
||||
DateTimeOffset? deployTime,
|
||||
DateTimeOffset savedAt,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_snapshotStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _snapshotStore.SaveAsync(
|
||||
new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The refresh was cancelled (service shutdown) before the write finished.
|
||||
// That is not a persistence failure — do not log it as a warning.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
List<GalaxyObject> objects = new(hierarchy.Count);
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
||||
{
|
||||
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
||||
{
|
||||
return snapshot.Status;
|
||||
}
|
||||
|
||||
if (snapshot.LastSuccessAt is { } success
|
||||
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
|
||||
{
|
||||
return GalaxyCacheStatus.Stale;
|
||||
}
|
||||
|
||||
return snapshot.Status;
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of the Galaxy Repository browse data held by
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
||||
/// materialized object list and precomputed hierarchy index.
|
||||
/// </summary>
|
||||
/// <param name="Status">The cache freshness state at the time the entry was produced.</param>
|
||||
/// <param name="Sequence">Monotonically increasing per process start; bumped on each heavy refresh.</param>
|
||||
/// <param name="LastQueriedAt">UTC wall-clock of the most recent refresh attempt.</param>
|
||||
/// <param name="LastSuccessAt">UTC wall-clock of the most recent successful refresh.</param>
|
||||
/// <param name="LastDeployTime">The <c>galaxy.time_of_last_deploy</c> the data was pulled at.</param>
|
||||
/// <param name="LastError">The most recent refresh error message, or <see langword="null"/>.</param>
|
||||
/// <param name="Objects">The materialized Galaxy object list.</param>
|
||||
/// <param name="Index">Precomputed lookup structures over <paramref name="Objects"/>.</param>
|
||||
/// <param name="ObjectCount">Number of objects in the hierarchy.</param>
|
||||
/// <param name="AreaCount">Number of area objects in the hierarchy.</param>
|
||||
/// <param name="AttributeCount">Number of attributes across all objects.</param>
|
||||
/// <param name="HistorizedAttributeCount">Number of historized attributes.</param>
|
||||
/// <param name="AlarmAttributeCount">Number of alarm-bearing attributes.</param>
|
||||
public sealed record GalaxyHierarchyCacheEntry(
|
||||
GalaxyCacheStatus Status,
|
||||
long Sequence,
|
||||
DateTimeOffset? LastQueriedAt,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
DateTimeOffset? LastDeployTime,
|
||||
string? LastError,
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
GalaxyHierarchyIndex Index,
|
||||
int ObjectCount,
|
||||
int AreaCount,
|
||||
int AttributeCount,
|
||||
int HistorizedAttributeCount,
|
||||
int AlarmAttributeCount)
|
||||
{
|
||||
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
|
||||
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
|
||||
Status: GalaxyCacheStatus.Unknown,
|
||||
Sequence: 0,
|
||||
LastQueriedAt: null,
|
||||
LastSuccessAt: null,
|
||||
LastDeployTime: null,
|
||||
LastError: null,
|
||||
Objects: Array.Empty<GalaxyObject>(),
|
||||
Index: GalaxyHierarchyIndex.Empty,
|
||||
ObjectCount: 0,
|
||||
AreaCount: 0,
|
||||
AttributeCount: 0,
|
||||
HistorizedAttributeCount: 0,
|
||||
AlarmAttributeCount: 0);
|
||||
|
||||
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
|
||||
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Precomputed lookup structures over a materialized Galaxy object list. Built once per
|
||||
/// cache entry so browse/discover handlers can resolve roots/parents by id, tag name, or
|
||||
/// contained path in O(1), enumerate direct children, and resolve tag addresses to objects
|
||||
/// or attributes without rescanning the full object list.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyIndex
|
||||
{
|
||||
private GalaxyHierarchyIndex(
|
||||
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByTagName,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByContainedPath)
|
||||
{
|
||||
ObjectViews = objectViews;
|
||||
ObjectViewsById = objectViewsById;
|
||||
TagsByAddress = tagsByAddress;
|
||||
ChildrenByParent = childrenByParent;
|
||||
ObjectViewsByTagName = objectViewsByTagName;
|
||||
ObjectViewsByContainedPath = objectViewsByContainedPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
|
||||
public static GalaxyHierarchyIndex Empty { get; } = new(
|
||||
Array.Empty<GalaxyObjectView>(),
|
||||
new Dictionary<int, GalaxyObjectView>(),
|
||||
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>(),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Gets the object views.</summary>
|
||||
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||
|
||||
/// <summary>Gets the object views indexed by gobject id.</summary>
|
||||
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
|
||||
|
||||
/// <summary>Gets tags indexed by address.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
|
||||
|
||||
/// <summary>Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase).</summary>
|
||||
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by <see cref="GalaxyObject.TagName"/> (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByTagName { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByContainedPath { get; }
|
||||
|
||||
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
|
||||
/// <param name="objects">The Galaxy objects to index.</param>
|
||||
/// <returns>A new Galaxy hierarchy index.</returns>
|
||||
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
|
||||
{
|
||||
if (objects.Count == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
Dictionary<int, GalaxyObject> objectsById = new();
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
objectsById.TryAdd(obj.GobjectId, obj);
|
||||
}
|
||||
|
||||
List<GalaxyObjectView> views = new(objects.Count);
|
||||
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
string path = BuildContainedPath(obj, objectsById);
|
||||
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
|
||||
GalaxyObjectView view = new(obj, path, depth);
|
||||
views.Add(view);
|
||||
viewsById.TryAdd(obj.GobjectId, view);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||
{
|
||||
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||
viewsByTagName.TryAdd(obj.TagName, view);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
viewsByContainedPath.TryAdd(path, view);
|
||||
}
|
||||
|
||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
|
||||
{
|
||||
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<int, List<GalaxyObjectView>> childrenByParent = new();
|
||||
foreach (GalaxyObjectView view in views)
|
||||
{
|
||||
int parentKey = view.Object.ParentGobjectId;
|
||||
// Treat self-parented (corrupt) rows as roots.
|
||||
if (parentKey == view.Object.GobjectId)
|
||||
{
|
||||
parentKey = 0;
|
||||
}
|
||||
// Re-root orphans whose parent object is absent from the set (e.g. a deleted or
|
||||
// never-loaded container area). Otherwise they bucket under a phantom parent id
|
||||
// that is never reached from the root, so they vanish from browse entirely.
|
||||
else if (parentKey != 0 && !objectsById.ContainsKey(parentKey))
|
||||
{
|
||||
parentKey = 0;
|
||||
}
|
||||
if (!childrenByParent.TryGetValue(parentKey, out List<GalaxyObjectView>? bucket))
|
||||
{
|
||||
bucket = [];
|
||||
childrenByParent[parentKey] = bucket;
|
||||
}
|
||||
bucket.Add(view);
|
||||
}
|
||||
|
||||
foreach (List<GalaxyObjectView> bucket in childrenByParent.Values)
|
||||
{
|
||||
bucket.Sort(CompareByAreaThenDisplayName);
|
||||
}
|
||||
|
||||
Dictionary<int, IReadOnlyList<GalaxyObjectView>> readOnlyChildren = new(childrenByParent.Count);
|
||||
foreach (KeyValuePair<int, List<GalaxyObjectView>> kvp in childrenByParent)
|
||||
{
|
||||
readOnlyChildren[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return new GalaxyHierarchyIndex(
|
||||
views,
|
||||
viewsById,
|
||||
tagsByAddress,
|
||||
readOnlyChildren,
|
||||
viewsByTagName,
|
||||
viewsByContainedPath);
|
||||
}
|
||||
|
||||
private static string BuildContainedPath(
|
||||
GalaxyObject obj,
|
||||
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
||||
{
|
||||
Stack<string> names = new();
|
||||
HashSet<int> seen = [];
|
||||
GalaxyObject? current = obj;
|
||||
while (current is not null && seen.Add(current.GobjectId))
|
||||
{
|
||||
names.Push(ResolvePathSegment(current));
|
||||
current = current.ParentGobjectId != 0
|
||||
&& objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
|
||||
? parent
|
||||
: null;
|
||||
}
|
||||
|
||||
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
|
||||
}
|
||||
|
||||
private static string ResolvePathSegment(GalaxyObject obj)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||
{
|
||||
return obj.ContainedName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||
{
|
||||
return obj.BrowseName;
|
||||
}
|
||||
|
||||
return obj.TagName;
|
||||
}
|
||||
|
||||
private static int CompareByAreaThenDisplayName(GalaxyObjectView left, GalaxyObjectView right)
|
||||
{
|
||||
if (left.Object.IsArea != right.Object.IsArea)
|
||||
{
|
||||
return left.Object.IsArea ? -1 : 1;
|
||||
}
|
||||
return string.Compare(DisplayNameOf(left), DisplayNameOf(right), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string DisplayNameOf(GalaxyObjectView view)
|
||||
{
|
||||
GalaxyObject obj = view.Object;
|
||||
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||
{
|
||||
return obj.BrowseName;
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||
{
|
||||
return obj.ContainedName;
|
||||
}
|
||||
return obj.TagName;
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Projects a <c>DiscoverHierarchy</c> request against an immutable
|
||||
/// <see cref="GalaxyHierarchyCacheEntry"/>: applies the root/depth/category/template/glob
|
||||
/// filters, pages the result, and memoizes the filtered list per cache-entry instance so
|
||||
/// paging is O(pageSize) rather than O(total) per page. Pure and side-effect free.
|
||||
/// </summary>
|
||||
public static class GalaxyHierarchyProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-cache-entry memo of filtered, ordered <see cref="GalaxyObjectView"/> lists
|
||||
/// keyed by filter signature. Without it, paging through a large hierarchy
|
||||
/// re-applies every filter and re-scans the full <see cref="GalaxyHierarchyIndex.ObjectViews"/>
|
||||
/// collection on every page — O(total) per page, O(total²/pageSize) end-to-end.
|
||||
/// With it, the first page builds the filtered list and each subsequent page is an
|
||||
/// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so
|
||||
/// when the cache publishes a new entry the stale memo becomes unreachable and is
|
||||
/// reclaimed with it — no explicit invalidation needed.
|
||||
/// </summary>
|
||||
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
|
||||
|
||||
/// <summary>Projects a discovery request against a cache entry and returns all matching objects.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
/// <param name="request">The discovery hierarchy request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||
public static GalaxyHierarchyQueryResult Project(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
||||
{
|
||||
return Project(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs,
|
||||
offset: 0,
|
||||
pageSize: int.MaxValue);
|
||||
}
|
||||
|
||||
/// <summary>Projects a discovery request with paging against a cache entry and returns a page of matching objects.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
/// <param name="request">The discovery hierarchy request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||
/// <param name="offset">The zero-based offset into the result set.</param>
|
||||
/// <param name="pageSize">The maximum number of results to return.</param>
|
||||
public static GalaxyHierarchyQueryResult Project(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int offset,
|
||||
int pageSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||
}
|
||||
|
||||
int? maxDepth = request.MaxDepth;
|
||||
if (maxDepth < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
||||
}
|
||||
|
||||
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs);
|
||||
IReadOnlyList<GalaxyObjectView> matchedViews = GetFilteredViews(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs,
|
||||
maxDepth,
|
||||
filterSignature);
|
||||
|
||||
bool includeAttributes = IncludeAttributes(request);
|
||||
List<GalaxyObject> page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset)));
|
||||
int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count);
|
||||
for (int index = offset; index < end; index++)
|
||||
{
|
||||
page.Add(CloneObject(matchedViews[index].Object, includeAttributes));
|
||||
}
|
||||
|
||||
return new GalaxyHierarchyQueryResult(
|
||||
page,
|
||||
matchedViews.Count,
|
||||
filterSignature);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObjectView> GetFilteredViews(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int? maxDepth,
|
||||
string filterSignature)
|
||||
{
|
||||
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||
// memo so a bad root surfaces consistently regardless of cache state.
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, entry.Index);
|
||||
|
||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||
|
||||
return memo.GetOrAdd(
|
||||
filterSignature,
|
||||
static (_, state) =>
|
||||
{
|
||||
List<GalaxyObjectView> matched = [];
|
||||
foreach (GalaxyObjectView view in state.Views)
|
||||
{
|
||||
if (MatchesRoot(view, state.Root, state.MaxDepth)
|
||||
&& MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)
|
||||
&& MatchesFilters(view.Object, state.Request))
|
||||
{
|
||||
matched.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
},
|
||||
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
|
||||
}
|
||||
|
||||
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
/// <param name="tagAddress">The tag address to search for.</param>
|
||||
public static GalaxyObject? FindObjectForTag(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
string tagAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||
? lookup.Object
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
/// <param name="tagAddress">The tag address to search for.</param>
|
||||
public static GalaxyAttribute? FindAttributeForTag(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
string tagAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||
? lookup.Attribute
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||
/// <param name="gobjectId">The Galaxy object ID.</param>
|
||||
public static string GetContainedPath(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
int gobjectId)
|
||||
{
|
||||
return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
|
||||
? view.ContainedPath
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static GalaxyObjectView? ResolveRoot(
|
||||
DiscoverHierarchyRequest request,
|
||||
GalaxyHierarchyIndex index)
|
||||
{
|
||||
GalaxyObjectView? root = request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId =>
|
||||
index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName =>
|
||||
index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath =>
|
||||
index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found."));
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool MatchesRoot(
|
||||
GalaxyObjectView view,
|
||||
GalaxyObjectView? root,
|
||||
int? maxDepth)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isRoot = view.Object.GobjectId == root.Object.GobjectId;
|
||||
bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isRoot && !isDescendant)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value;
|
||||
}
|
||||
|
||||
private static bool MatchesBrowseSubtrees(
|
||||
GalaxyObjectView view,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
return browseSubtreeGlobs is null
|
||||
|| browseSubtreeGlobs.Count == 0
|
||||
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(
|
||||
GalaxyObject obj,
|
||||
DiscoverHierarchyRequest request)
|
||||
{
|
||||
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string templateFilter in request.TemplateChainContains)
|
||||
{
|
||||
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IncludeAttributes(DiscoverHierarchyRequest request)
|
||||
{
|
||||
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||
}
|
||||
|
||||
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||
{
|
||||
GalaxyObject clone = source.Clone();
|
||||
if (!includeAttributes)
|
||||
{
|
||||
clone.Attributes.Clear();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||
/// <param name="request">The discovery hierarchy request.</param>
|
||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||
public static string ComputeFilterSignature(
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
builder.Append("root=").Append(request.RootCase).Append('|');
|
||||
builder.Append(request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath,
|
||||
_ => string.Empty,
|
||||
});
|
||||
builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "");
|
||||
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash, 0, 12);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Result of one <see cref="GalaxyHierarchyProjector.Project(GalaxyHierarchyCacheEntry, DiscoverHierarchyRequest, System.Collections.Generic.IReadOnlyList{string}, int, int)"/>
|
||||
/// call: a materialized page of matching objects, the total post-filter object count, and
|
||||
/// the stable filter signature used to bind page tokens.
|
||||
/// </summary>
|
||||
/// <param name="Objects">The page of matching objects.</param>
|
||||
/// <param name="TotalObjectCount">Total matching objects across the whole hierarchy (post-filter).</param>
|
||||
/// <param name="FilterSignature">Stable signature of the filter set, used to bind page tokens.</param>
|
||||
public sealed record GalaxyHierarchyQueryResult(
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
int TotalObjectCount,
|
||||
string FilterSignature);
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
|
||||
public sealed class GalaxyHierarchyRefreshService(
|
||||
IGalaxyHierarchyCache cache,
|
||||
IOptions<GalaxyRepositoryOptions> options,
|
||||
ILogger<GalaxyHierarchyRefreshService> logger,
|
||||
TimeProvider? timeProvider = null) : BackgroundService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A transient first-load failure (e.g. a TimeoutException or
|
||||
// Win32Exception from connection establishment, or a DbException
|
||||
// subtype the cache does not catch) must not fault this
|
||||
// BackgroundService and stop the whole host. The cache records
|
||||
// its own Unavailable/Stale status; the periodic tick below retries.
|
||||
logger.LogWarning(exception, "Initial Galaxy hierarchy cache load failed; will retry on the refresh interval.");
|
||||
}
|
||||
|
||||
using PeriodicTimer timer = new(interval, _timeProvider);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
|
||||
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyRow
|
||||
{
|
||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||
public int GobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the tag name.</summary>
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the contained name.</summary>
|
||||
public string ContainedName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the browse name.</summary>
|
||||
public string BrowseName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the parent Galaxy object identifier.</summary>
|
||||
public int ParentGobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an area.</summary>
|
||||
public bool IsArea { get; init; }
|
||||
|
||||
/// <summary>Gets the category identifier.</summary>
|
||||
public int CategoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the Galaxy object identifier of the host.</summary>
|
||||
public int HostedByGobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the template derivation chain.</summary>
|
||||
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// A serializable point-in-time copy of the Galaxy Repository browse data.
|
||||
/// Holds the raw hierarchy and attribute rowsets — not the materialized
|
||||
/// protobuf objects — so the restore path runs the exact same
|
||||
/// materialization as a live refresh. Persisted by
|
||||
/// <see cref="IGalaxyHierarchySnapshotStore"/> after a successful refresh
|
||||
/// and reloaded at startup when the Galaxy database is unreachable.
|
||||
/// </summary>
|
||||
/// <param name="LastDeployTime">
|
||||
/// The <c>galaxy.time_of_last_deploy</c> the rowsets were pulled at, or
|
||||
/// <see langword="null"/> when the Galaxy table reported no deploy. A later
|
||||
/// live refresh that observes this same timestamp can promote the restored
|
||||
/// entry to healthy without re-running the heavy queries.
|
||||
/// </param>
|
||||
/// <param name="SavedAt">UTC wall-clock when the snapshot was written to disk.</param>
|
||||
/// <param name="Hierarchy">The persisted object-hierarchy rowset.</param>
|
||||
/// <param name="Attributes">The persisted attribute rowset.</param>
|
||||
public sealed record GalaxyHierarchySnapshot(
|
||||
DateTimeOffset? LastDeployTime,
|
||||
DateTimeOffset SavedAt,
|
||||
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> Attributes);
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-file implementation of <see cref="IGalaxyHierarchySnapshotStore"/>.
|
||||
/// Writes the on-disk snapshot atomically (temp file + rename) so a crash
|
||||
/// mid-write can never leave a torn file, and ignores files whose schema
|
||||
/// version it does not recognize. When
|
||||
/// <see cref="GalaxyRepositoryOptions.PersistSnapshot"/> is <see langword="false"/>
|
||||
/// — or <see cref="GalaxyRepositoryOptions.SnapshotCachePath"/> is empty —
|
||||
/// both operations are no-ops. The snapshot path is fully consumer-supplied;
|
||||
/// this store imposes no platform-specific default, so it is cross-platform.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// On-disk format version. Bump this whenever the persisted shape changes
|
||||
/// in a way an older or newer consumer cannot read; a mismatched file is
|
||||
/// ignored rather than misparsed.
|
||||
/// </summary>
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly TimeSpan _writeTimeout;
|
||||
private readonly ILogger<GalaxyHierarchySnapshotStore>? _logger;
|
||||
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchySnapshotStore"/> class.</summary>
|
||||
/// <param name="options">Galaxy repository options carrying the snapshot path and enable flag.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public GalaxyHierarchySnapshotStore(
|
||||
IOptions<GalaxyRepositoryOptions> options,
|
||||
ILogger<GalaxyHierarchySnapshotStore>? logger = null)
|
||||
{
|
||||
GalaxyRepositoryOptions value = options.Value;
|
||||
_path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath)
|
||||
? value.SnapshotCachePath
|
||||
: null;
|
||||
_writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (_path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PersistedFile file = new(CurrentSchemaVersion, snapshot);
|
||||
|
||||
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Bound the write so a stuck disk — e.g. a SnapshotCachePath on an
|
||||
// unresponsive network share — cannot stall the caller. On the cache
|
||||
// refresh path that would otherwise pin the whole refresh loop.
|
||||
using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
writeCts.CancelAfter(_writeTimeout);
|
||||
|
||||
string? directory = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string tempPath = _path + ".tmp";
|
||||
await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(tempPath, _path, overwrite: true);
|
||||
_logger?.LogDebug(
|
||||
"Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).",
|
||||
_path,
|
||||
snapshot.Hierarchy.Count,
|
||||
snapshot.Attributes.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_path is null || !File.Exists(_path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
PersistedFile? file;
|
||||
await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
file = await JsonSerializer.DeserializeAsync<PersistedFile>(
|
||||
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.",
|
||||
_path);
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.Snapshot;
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A corrupt, truncated, locked, or access-denied snapshot file is an
|
||||
// expected failure mode for a disk cache — honor the Try contract and
|
||||
// return null rather than throwing.
|
||||
_logger?.LogWarning(
|
||||
exception,
|
||||
"Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.",
|
||||
_path);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the I/O gate. As a DI singleton the store is disposed once at host
|
||||
/// shutdown, by which point no save/load is in flight.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_ioGate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>On-disk envelope: a schema version plus the snapshot payload.</summary>
|
||||
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="GalaxyObject"/> paired with its computed contained path and hierarchy
|
||||
/// depth. Materialized once per cache entry by <see cref="GalaxyHierarchyIndex"/> so
|
||||
/// browse/discover projection can filter and page without recomputing paths.
|
||||
/// </summary>
|
||||
/// <param name="Object">The projected Galaxy object.</param>
|
||||
/// <param name="ContainedPath">The slash-delimited contained path from the hierarchy root.</param>
|
||||
/// <param name="Depth">The number of path segments from the root (zero for top-level objects).</param>
|
||||
public sealed record GalaxyObjectView(
|
||||
GalaxyObject Object,
|
||||
string ContainedPath,
|
||||
int Depth);
|
||||
@@ -0,0 +1,76 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="GalaxyHierarchyRow"/> + <see cref="GalaxyAttributeRow"/> rows produced
|
||||
/// by <see cref="GalaxyRepository"/> into <c>galaxy_repository.v1</c> proto messages.
|
||||
/// Pure function, separated so it can be unit-tested without a SQL connection.
|
||||
/// </summary>
|
||||
public static class GalaxyProtoMapper
|
||||
{
|
||||
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
||||
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
||||
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
||||
public static IEnumerable<GalaxyObject> MapHierarchy(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
yield return MapObject(row, attributesByGobjectId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
||||
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
||||
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
||||
public static GalaxyObject MapObject(
|
||||
GalaxyHierarchyRow row,
|
||||
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
||||
{
|
||||
GalaxyObject obj = new()
|
||||
{
|
||||
GobjectId = row.GobjectId,
|
||||
TagName = row.TagName,
|
||||
ContainedName = row.ContainedName,
|
||||
BrowseName = row.BrowseName,
|
||||
ParentGobjectId = row.ParentGobjectId,
|
||||
IsArea = row.IsArea,
|
||||
CategoryId = row.CategoryId,
|
||||
HostedByGobjectId = row.HostedByGobjectId,
|
||||
};
|
||||
obj.TemplateChain.AddRange(row.TemplateChain);
|
||||
|
||||
if (attributesByGobjectId.TryGetValue(row.GobjectId, out List<GalaxyAttributeRow>? attrs))
|
||||
{
|
||||
foreach (GalaxyAttributeRow attr in attrs)
|
||||
{
|
||||
obj.Attributes.Add(MapAttribute(attr));
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
||||
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
||||
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
||||
{
|
||||
AttributeName = row.AttributeName,
|
||||
FullTagReference = row.FullTagReference,
|
||||
MxDataType = row.MxDataType,
|
||||
DataTypeName = row.DataTypeName ?? string.Empty,
|
||||
IsArray = row.IsArray,
|
||||
ArrayDimension = row.ArrayDimension ?? 0,
|
||||
ArrayDimensionPresent = row.ArrayDimension.HasValue,
|
||||
MxAttributeCategory = row.MxAttributeCategory,
|
||||
SecurityClassification = row.SecurityClassification,
|
||||
IsHistorized = row.IsHistorized,
|
||||
IsAlarm = row.IsAlarm,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// SQL access to the AVEVA System Platform Galaxy Repository database.
|
||||
/// <para>
|
||||
/// <see cref="HierarchySql" /> is the query originally ported from the OtOpcUa
|
||||
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
||||
/// built-in attributes contributed by each object's primitives (from
|
||||
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
||||
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
||||
/// OtOpcUa query is not kept in sync.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||
{
|
||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is int i && i == 1;
|
||||
}
|
||||
catch (SqlException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
|
||||
{ CommandTimeout = options.CommandTimeoutSeconds };
|
||||
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is DateTime dt ? dt : null;
|
||||
}
|
||||
|
||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyHierarchyRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
|
||||
string[] templateChain = templateChainRaw.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
rows.Add(new GalaxyHierarchyRow
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
BrowseName = reader.GetString(3),
|
||||
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
|
||||
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
|
||||
CategoryId = Convert.ToInt32(reader.GetValue(6)),
|
||||
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
|
||||
TemplateChain = templateChain,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyAttributeRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(new GalaxyAttributeRow
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
AttributeName = reader.GetString(2),
|
||||
FullTagReference = reader.GetString(3),
|
||||
MxDataType = Convert.ToInt32(reader.GetValue(4)),
|
||||
DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
|
||||
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
|
||||
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
|
||||
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
||||
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
||||
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyAlarmAttributeRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(MapAlarmRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the SQL columns projected by <c>AlarmAttributesSql</c> onto a
|
||||
/// <see cref="GalaxyAlarmAttributeRow"/>.
|
||||
/// <para>
|
||||
/// <paramref name="fullTagReference"/> is the alarm-bearing attribute reference (e.g.
|
||||
/// <c>Tank01.Level.HiHi</c>), matching the same <c>full_tag_reference</c> projection
|
||||
/// of <see cref="AttributesSql"/> produces.
|
||||
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
||||
/// schema does not expose an ack-comment address and the watch-list resolver
|
||||
/// composes it later.
|
||||
/// </para>
|
||||
/// <paramref name="area"/> is the owning object's real Galaxy area (its alarm
|
||||
/// group), resolved via <c>gobject.area_gobject_id</c>; the watch-list resolver
|
||||
/// composes the canonical reference from it so the synthesized reference's group
|
||||
/// matches what the native alarmmgr (wnwrap) emits.
|
||||
/// Exposed internally so the derivation can be unit-tested without a database.
|
||||
/// </summary>
|
||||
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
||||
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
||||
/// <param name="area">The owning object's Galaxy area (the alarm group).</param>
|
||||
internal static GalaxyAlarmAttributeRow MapAlarmRow(
|
||||
string fullTagReference,
|
||||
string sourceObjectReference,
|
||||
string area) => new()
|
||||
{
|
||||
FullTagReference = fullTagReference,
|
||||
SourceObjectReference = sourceObjectReference,
|
||||
Area = area,
|
||||
AckCommentSubtag = string.Empty,
|
||||
};
|
||||
|
||||
// Area objects (category 13) are returned even when undeployed (deployed_package_id = 0):
|
||||
// they are organizational/model nodes that group deployed objects, so excluding them
|
||||
// orphans every area whose containing area is not itself deployed. All non-area objects
|
||||
// still require deployment. Orphans left by a missing/deleted parent area are re-rooted
|
||||
// by GalaxyHierarchyIndex.Build so nothing disappears from browse.
|
||||
private const string HierarchySql = @"
|
||||
;WITH template_chain AS (
|
||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
|
||||
UNION ALL
|
||||
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
|
||||
FROM template_chain tc
|
||||
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
|
||||
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
|
||||
)
|
||||
SELECT DISTINCT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
g.contained_name,
|
||||
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
|
||||
THEN g.tag_name
|
||||
ELSE g.contained_name
|
||||
END AS browse_name,
|
||||
CASE WHEN g.contained_by_gobject_id = 0
|
||||
THEN g.area_gobject_id
|
||||
ELSE g.contained_by_gobject_id
|
||||
END AS parent_gobject_id,
|
||||
CASE WHEN td.category_id = 13
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS is_area,
|
||||
td.category_id AS category_id,
|
||||
g.hosted_by_gobject_id AS hosted_by_gobject_id,
|
||||
ISNULL(
|
||||
STUFF((
|
||||
SELECT '|' + tc.template_tag_name
|
||||
FROM template_chain tc
|
||||
WHERE tc.instance_gobject_id = g.gobject_id
|
||||
ORDER BY tc.depth
|
||||
FOR XML PATH('')
|
||||
), 1, 1, ''),
|
||||
''
|
||||
) AS template_chain
|
||||
FROM gobject g
|
||||
INNER JOIN template_definition td
|
||||
ON g.template_definition_id = td.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND g.is_template = 0
|
||||
AND (g.deployed_package_id <> 0 OR td.category_id = 13)
|
||||
ORDER BY parent_gobject_id, g.tag_name";
|
||||
|
||||
// Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two
|
||||
// kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute`
|
||||
// body, src_pri 0) and the built-in attributes every object inherits from its primitives
|
||||
// (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in
|
||||
// attributes are why engine/platform objects and extension sub-attributes such as
|
||||
// `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the
|
||||
// `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the
|
||||
// `_`-prefix and `.Description` name exclusions apply) and are never flagged
|
||||
// `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an
|
||||
// extension, not the extension's machinery leaves.
|
||||
private const string AttributesSql = @"
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
),
|
||||
candidate AS (
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array,
|
||||
CASE WHEN da.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
||||
ELSE NULL END AS array_dimension,
|
||||
da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND da.attribute_name NOT LIKE '[_]%'
|
||||
AND da.attribute_name NOT LIKE '%.Description'
|
||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name,
|
||||
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||
THEN ad.attribute_name
|
||||
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||
ad.mx_data_type, ad.is_array,
|
||||
CASE WHEN ad.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
||||
ELSE NULL END AS array_dimension,
|
||||
ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND ad.attribute_name NOT LIKE '[_]%'
|
||||
AND ad.attribute_name NOT LIKE '%.Description'
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*, ROW_NUMBER() OVER (
|
||||
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||
FROM candidate c
|
||||
)
|
||||
SELECT
|
||||
r.gobject_id, r.tag_name, r.attribute_name,
|
||||
r.tag_name + '.' + r.attribute_name
|
||||
+ CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference,
|
||||
r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension,
|
||||
r.mx_attribute_category, r.security_classification,
|
||||
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
|
||||
WHERE dpc2.gobject_id = r.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_historized,
|
||||
CASE WHEN r.src_pri = 0 AND EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||
WHERE dpc2.gobject_id = r.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_alarm
|
||||
FROM ranked r
|
||||
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||
WHERE r.rn = 1
|
||||
ORDER BY r.tag_name, r.attribute_name";
|
||||
|
||||
// Returns one row per alarm-bearing attribute across all deployed objects. The three
|
||||
// projected columns (full_tag_reference, source_object_reference, area_name) are mapped
|
||||
// by MapAlarmRow. Only attributes whose owning object has an AlarmExtension primitive
|
||||
// instance matching the attribute name are returned, which is why the EXISTS correlated
|
||||
// sub-query against deployed_package_chain is needed rather than relying solely on
|
||||
// dynamic_attribute.mx_attribute_category.
|
||||
private const string AlarmAttributesSql = @"
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
),
|
||||
candidate AS (
|
||||
SELECT dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN dynamic_attribute da ON da.package_id = dpc.package_id
|
||||
INNER JOIN gobject g ON g.gobject_id = dpc.gobject_id
|
||||
INNER JOIN template_definition td ON td.template_definition_id = g.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND da.attribute_name NOT LIKE '[_]%'
|
||||
AND da.attribute_name NOT LIKE '%.Description'
|
||||
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*, ROW_NUMBER() OVER (
|
||||
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.depth) AS rn
|
||||
FROM candidate c
|
||||
)
|
||||
SELECT
|
||||
r.tag_name + '.' + r.attribute_name AS full_tag_reference,
|
||||
r.tag_name AS source_object_reference,
|
||||
ISNULL(area.tag_name, '') AS area_name
|
||||
FROM ranked r
|
||||
INNER JOIN gobject g ON g.gobject_id = r.gobject_id
|
||||
LEFT JOIN gobject area ON area.gobject_id = g.area_gobject_id
|
||||
WHERE r.rn = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM deployed_package_chain dpc2
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.attribute_name
|
||||
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
|
||||
WHERE dpc2.gobject_id = r.gobject_id
|
||||
)
|
||||
ORDER BY r.tag_name, r.attribute_name";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Connection settings for the AVEVA System Platform Galaxy Repository database.
|
||||
/// <para>
|
||||
/// <see cref="SectionName"/> is a generic default; the DI extension accepts an explicit
|
||||
/// configuration section path so a consumer can bind from its own section (e.g.
|
||||
/// <c>HistorianGateway:Galaxy</c>).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Generic default configuration section name. The DI extension accepts an explicit
|
||||
/// section path, so a consumer may bind from a different section (e.g.
|
||||
/// <c>HistorianGateway:Galaxy</c>).
|
||||
/// </summary>
|
||||
public const string SectionName = "GalaxyRepository";
|
||||
|
||||
/// <summary>
|
||||
/// Default SQL Server connection string for the Galaxy Repository database.
|
||||
/// Single source of truth shared with the integration-test fallback so the
|
||||
/// production default and the live-test default cannot drift.
|
||||
/// </summary>
|
||||
public const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
|
||||
public string ConnectionString { get; init; } = DefaultConnectionString;
|
||||
|
||||
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
|
||||
public int CommandTimeoutSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
|
||||
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
||||
/// </summary>
|
||||
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the latest successful Galaxy browse dataset is persisted to disk. When
|
||||
/// enabled, the cache reloads that snapshot at startup so clients can still browse
|
||||
/// last-known data while the Galaxy database is unreachable.
|
||||
/// </summary>
|
||||
public bool PersistSnapshot { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
||||
/// <see cref="PersistSnapshot"/> is <see langword="false"/>. There is no built-in
|
||||
/// default path — the consumer supplies a cross-platform-friendly path appropriate to
|
||||
/// its host. When left empty and <see cref="PersistSnapshot"/> is enabled, the
|
||||
/// snapshot store (a later task) decides where to write.
|
||||
/// </summary>
|
||||
public string SnapshotCachePath { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Resolution result for a tag address: the owning <see cref="GalaxyObject"/>, the
|
||||
/// specific <see cref="GalaxyAttribute"/> when the address names an attribute (otherwise
|
||||
/// <see langword="null"/>), and the object's contained path.
|
||||
/// </summary>
|
||||
/// <param name="Object">The Galaxy object that owns the looked-up address.</param>
|
||||
/// <param name="Attribute">The matched attribute, or <see langword="null"/> when the address names an object.</param>
|
||||
/// <param name="ContainedPath">The owning object's contained path.</param>
|
||||
public sealed record GalaxyTagLookup(
|
||||
GalaxyObject Object,
|
||||
GalaxyAttribute? Attribute,
|
||||
string ContainedPath);
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Grpc.Core;
|
||||
using ProtoGalaxyRepository = ZB.MOM.WW.GalaxyRepository.Grpc.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Reusable gRPC surface that exposes the Galaxy Repository to clients. Hosted by any
|
||||
/// consuming gateway (e.g. MxAccessGateway or the HistorianGateway sidecar) via
|
||||
/// <see cref="DependencyInjection.GalaxyRepositoryServiceCollectionExtensions.MapZbGalaxyRepository"/>.
|
||||
/// <para>
|
||||
/// <c>DiscoverHierarchy</c> and <c>GetLastDeployTime</c> serve from
|
||||
/// <see cref="IGalaxyHierarchyCache"/> so many clients share a single SQL pull.
|
||||
/// <c>WatchDeployEvents</c> streams events from <see cref="IGalaxyDeployNotifier"/>.
|
||||
/// <c>TestConnection</c> remains a direct SQL probe since callers use it as a health check.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per-identity browse-subtree filtering is delegated to the injected
|
||||
/// <see cref="IGalaxyBrowseScopeProvider"/>. The default
|
||||
/// <see cref="NullGalaxyBrowseScopeProvider"/> returns <c>null</c> globs, so the full
|
||||
/// hierarchy is projected and behavior is unchanged. A hosting gateway can register its
|
||||
/// own provider to scope <c>DiscoverHierarchy</c>/<c>BrowseChildren</c> results — and the
|
||||
/// <c>object_count</c>/<c>attribute_count</c> totals streamed by <c>WatchDeployEvents</c> —
|
||||
/// to the caller's allowed subtrees.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="repository">Direct SQL surface used by <c>TestConnection</c>.</param>
|
||||
/// <param name="cache">Shared hierarchy cache that <c>DiscoverHierarchy</c>/<c>BrowseChildren</c>/<c>GetLastDeployTime</c> serve from.</param>
|
||||
/// <param name="notifier">Deploy-event source streamed by <c>WatchDeployEvents</c>.</param>
|
||||
/// <param name="scope">Resolves the per-caller browse-subtree globs applied to browse/discover results.</param>
|
||||
public sealed class GalaxyRepositoryGrpcService(
|
||||
IGalaxyRepository repository,
|
||||
IGalaxyHierarchyCache cache,
|
||||
IGalaxyDeployNotifier notifier,
|
||||
IGalaxyBrowseScopeProvider scope) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||
{
|
||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||
private const int DefaultDiscoverPageSize = 1000;
|
||||
private const int MaxDiscoverPageSize = 5000;
|
||||
private const int DefaultBrowsePageSize = 500;
|
||||
// MaxBrowsePageSize reuses MaxDiscoverPageSize (5000) — same cap.
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TestConnectionReply> TestConnection(
|
||||
TestConnectionRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
|
||||
return new TestConnectionReply { Ok = ok };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
|
||||
GetLastDeployTimeRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
GetLastDeployTimeReply reply = new() { Present = entry.LastDeployTime.HasValue };
|
||||
if (entry.LastDeployTime.HasValue)
|
||||
{
|
||||
reply.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(entry.LastDeployTime.Value);
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
|
||||
DiscoverHierarchyRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
int pageSize = ResolvePageSize(request.PageSize);
|
||||
IReadOnlyList<string>? browseSubtrees = scope.ResolveBrowseSubtrees(context);
|
||||
string filterSignature = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
GalaxyHierarchyQueryResult query = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
request,
|
||||
browseSubtrees,
|
||||
pageToken.Offset,
|
||||
pageSize);
|
||||
int offset = pageToken.Offset;
|
||||
if (offset > query.TotalObjectCount)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
||||
}
|
||||
|
||||
DiscoverHierarchyReply reply = new()
|
||||
{
|
||||
TotalObjectCount = query.TotalObjectCount,
|
||||
};
|
||||
reply.Objects.Add(query.Objects);
|
||||
|
||||
int nextOffset = offset + query.Objects.Count;
|
||||
if (nextOffset < query.TotalObjectCount)
|
||||
{
|
||||
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<BrowseChildrenReply> BrowseChildren(
|
||||
BrowseChildrenRequest request,
|
||||
ServerCallContext context)
|
||||
{
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
int pageSize = ResolveBrowsePageSize(request.PageSize);
|
||||
IReadOnlyList<string>? browseSubtrees = scope.ResolveBrowseSubtrees(context);
|
||||
|
||||
// Resolve the parent id once so the page-token signature can include it
|
||||
// and the projector sees the same resolved id when memoizing. The projector
|
||||
// re-resolves internally; with the by-name/by-path indexes on
|
||||
// GalaxyHierarchyIndex that second call is O(1), so the redundancy is cheap
|
||||
// and keeps the projector self-contained.
|
||||
int parentId = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||
string filterSignature = GalaxyBrowseProjector.ComputeFilterSignature(
|
||||
request, browseSubtrees, parentId);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
request,
|
||||
browseSubtrees,
|
||||
pageToken.Offset,
|
||||
pageSize);
|
||||
|
||||
if (pageToken.Offset > result.TotalChildCount)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"BrowseChildren page_token is outside the current children set."));
|
||||
}
|
||||
|
||||
BrowseChildrenReply reply = new()
|
||||
{
|
||||
TotalChildCount = result.TotalChildCount,
|
||||
CacheSequence = (ulong)entry.Sequence,
|
||||
};
|
||||
reply.Children.Add(result.Children);
|
||||
reply.ChildHasChildren.Add(result.ChildHasChildren);
|
||||
|
||||
int nextOffset = pageToken.Offset + result.Children.Count;
|
||||
if (nextOffset < result.TotalChildCount)
|
||||
{
|
||||
reply.NextPageToken = FormatPageToken(entry.Sequence, result.FilterSignature, nextOffset);
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task WatchDeployEvents(
|
||||
WatchDeployEventsRequest request,
|
||||
IServerStreamWriter<DeployEvent> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
|
||||
|
||||
// The caller's identity (and therefore its browse-subtree constraints) is fixed
|
||||
// for the lifetime of the stream, so resolve the subtrees once rather than per
|
||||
// streamed event.
|
||||
IReadOnlyList<string>? browseSubtrees = scope.ResolveBrowseSubtrees(context);
|
||||
|
||||
await foreach (GalaxyDeployEventInfo info in notifier
|
||||
.SubscribeAsync(context.CancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// Suppress the initial bootstrap event when the client already knows about
|
||||
// this deploy time. We only suppress the first one — subsequent events fire
|
||||
// on actual changes, so they always pass.
|
||||
if (lastSeen is { } seen && info.TimeOfLastDeploy == seen)
|
||||
{
|
||||
lastSeen = null;
|
||||
continue;
|
||||
}
|
||||
lastSeen = null;
|
||||
|
||||
await responseStream.WriteAsync(MapDeployEvent(info, browseSubtrees), context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitForCacheBootstrap(CancellationToken cancellationToken)
|
||||
{
|
||||
if (cache.Current.HasData || cache.Current.Status == GalaxyCacheStatus.Unavailable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using CancellationTokenSource budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
budget.CancelAfter(FirstLoadWaitBudget);
|
||||
try
|
||||
{
|
||||
await cache.WaitForFirstLoadAsync(budget.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Budget elapsed; fall through and let the caller see the current
|
||||
// (possibly Unknown/Unavailable) entry.
|
||||
}
|
||||
}
|
||||
|
||||
private DeployEvent MapDeployEvent(
|
||||
GalaxyDeployEventInfo info,
|
||||
IReadOnlyList<string>? browseSubtrees)
|
||||
{
|
||||
int objectCount = info.ObjectCount;
|
||||
int attributeCount = info.AttributeCount;
|
||||
if (browseSubtrees is { Count: > 0 } && cache.Current.HasData)
|
||||
{
|
||||
GalaxyHierarchyQueryResult scoped = GalaxyHierarchyProjector.Project(
|
||||
cache.Current,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtrees);
|
||||
objectCount = scoped.TotalObjectCount;
|
||||
attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count);
|
||||
}
|
||||
|
||||
DeployEvent ev = new()
|
||||
{
|
||||
Sequence = (ulong)info.Sequence,
|
||||
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||
ObjectCount = objectCount,
|
||||
AttributeCount = attributeCount,
|
||||
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
||||
};
|
||||
if (info.TimeOfLastDeploy.HasValue)
|
||||
{
|
||||
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(info.TimeOfLastDeploy.Value);
|
||||
}
|
||||
return ev;
|
||||
}
|
||||
|
||||
private static string ResolveUnavailableMessage(GalaxyHierarchyCacheEntry entry) => entry.Status switch
|
||||
{
|
||||
GalaxyCacheStatus.Unknown => "Galaxy cache has not completed its initial load yet.",
|
||||
GalaxyCacheStatus.Unavailable => "Galaxy repository is unavailable.",
|
||||
_ => "Galaxy cache has no data available.",
|
||||
};
|
||||
|
||||
private static int ResolvePageSize(int requestedPageSize)
|
||||
{
|
||||
if (requestedPageSize < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_size must be greater than zero when provided."));
|
||||
}
|
||||
|
||||
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
private static int ResolveBrowsePageSize(int requested)
|
||||
{
|
||||
if (requested < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"BrowseChildren page_size must be greater than zero when provided."));
|
||||
}
|
||||
int pageSize = requested == 0 ? DefaultBrowsePageSize : requested;
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
private static string FormatPageToken(long sequence, string filterSignature, int offset)
|
||||
{
|
||||
return string.Concat(
|
||||
sequence.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
":",
|
||||
filterSignature,
|
||||
":",
|
||||
offset.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pageToken))
|
||||
{
|
||||
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
|
||||
}
|
||||
|
||||
string[] parts = pageToken.Split(':', count: 3);
|
||||
if (parts.Length != 3
|
||||
|| !long.TryParse(
|
||||
parts[0],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out long sequence)
|
||||
|| !int.TryParse(
|
||||
parts[2],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int offset)
|
||||
|| offset < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"page_token is invalid."));
|
||||
}
|
||||
|
||||
if (sequence != currentSequence)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"page_token is stale."));
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"page_token does not match the current filters."));
|
||||
}
|
||||
|
||||
return new PageToken(sequence, parts[1], offset);
|
||||
}
|
||||
|
||||
private sealed record PageToken(long Sequence, string FilterSignature, int Offset);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the browse-subtree glob patterns the current caller is allowed to see.
|
||||
/// Lets a hosting gateway scope <see cref="GalaxyRepositoryGrpcService"/> results per
|
||||
/// identity without the library knowing the host's authorization model. The default
|
||||
/// <see cref="NullGalaxyBrowseScopeProvider"/> applies no scoping (full hierarchy).
|
||||
/// </summary>
|
||||
public interface IGalaxyBrowseScopeProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the allowed browse-subtree globs for the current call, or
|
||||
/// <see langword="null"/>/empty for no restriction (full hierarchy).
|
||||
/// </summary>
|
||||
/// <param name="context">The gRPC server call context for the current request.</param>
|
||||
/// <returns>The allowed browse-subtree globs, or <see langword="null"/> for no restriction.</returns>
|
||||
IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context);
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
/// <summary>Default <see cref="IGalaxyBrowseScopeProvider"/> that applies no scoping (full hierarchy).</summary>
|
||||
public sealed class NullGalaxyBrowseScopeProvider : IGalaxyBrowseScopeProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context) => null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>Publishes Galaxy repository deploy events to subscribers.</summary>
|
||||
public interface IGalaxyDeployNotifier
|
||||
{
|
||||
/// <summary>The most recently published event, or null if no event has fired yet.</summary>
|
||||
GalaxyDeployEventInfo? Latest { get; }
|
||||
|
||||
/// <summary>Publishes a deploy event to all current subscribers and stores it as Latest.</summary>
|
||||
/// <param name="info">The deploy event to publish.</param>
|
||||
void Publish(GalaxyDeployEventInfo info);
|
||||
|
||||
/// <summary>Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Async enumerable of deploy events.</returns>
|
||||
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>Cache for Galaxy Repository hierarchy data.</summary>
|
||||
public interface IGalaxyHierarchyCache
|
||||
{
|
||||
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
|
||||
GalaxyHierarchyCacheEntry Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Forces a refresh against the Galaxy Repository. Performs a cheap
|
||||
/// <c>time_of_last_deploy</c> probe first and only re-queries the heavy hierarchy +
|
||||
/// attributes rowsets when the deploy time has changed since the last successful
|
||||
/// refresh.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task RefreshAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Awaits the first completed refresh attempt (success or failure). Useful for
|
||||
/// gRPC handlers that want to serve from cache without returning Unavailable on the
|
||||
/// very first request after the service starts.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the latest Galaxy Repository browse dataset to disk and reloads
|
||||
/// it at startup. Lets <see cref="GalaxyHierarchyCache"/> serve last-known
|
||||
/// browse data when the Galaxy database is unreachable on a cold start.
|
||||
/// </summary>
|
||||
public interface IGalaxyHierarchySnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Writes <paramref name="snapshot"/> to disk, replacing any previous
|
||||
/// snapshot atomically. A no-op when snapshot persistence is disabled.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">The browse dataset to persist.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reads the persisted Galaxy browse dataset.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>
|
||||
/// The persisted snapshot, or <see langword="null"/> when none exists,
|
||||
/// persistence is disabled, or the on-disk file uses an unrecognized
|
||||
/// schema version.
|
||||
/// </returns>
|
||||
Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over <see cref="GalaxyRepository"/>: the read-only SQL surface over the
|
||||
/// AVEVA System Platform Galaxy Repository database. Exists so consumers (and the cache
|
||||
/// layer, a later task) can be unit-tested against an in-memory fake without standing up a
|
||||
/// real <c>Microsoft.Data.SqlClient</c> <c>SqlConnection</c> against a bogus host/port.
|
||||
/// </summary>
|
||||
public interface IGalaxyRepository
|
||||
{
|
||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Returns the alarm-bearing attributes across deployed Galaxy objects.</summary>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The alarm-bearing attribute rows.</returns>
|
||||
Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default);
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package galaxy_repository.v1;
|
||||
|
||||
option csharp_namespace = "ZB.MOM.WW.GalaxyRepository.Grpc";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// Wire-compatibility policy (ProtobufStyleGuide): this contract evolves
|
||||
// additively only. Never renumber or repurpose an existing field number or
|
||||
// enum value. When a field or enum value is removed, add a `reserved` range
|
||||
// (and `reserved` name) covering it in the same change so a future editor
|
||||
// cannot accidentally reuse the retired tag. There are no `reserved`
|
||||
// declarations today because no field or enum value has ever been removed.
|
||||
|
||||
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||
// object's dynamic attributes so they know what tag references to subscribe
|
||||
// to via the MxAccessGateway service.
|
||||
service GalaxyRepository {
|
||||
rpc TestConnection(TestConnectionRequest) returns (TestConnectionReply);
|
||||
rpc GetLastDeployTime(GetLastDeployTimeRequest) returns (GetLastDeployTimeReply);
|
||||
rpc DiscoverHierarchy(DiscoverHierarchyRequest) returns (DiscoverHierarchyReply);
|
||||
|
||||
// Server-stream of deploy events. The server emits the current state immediately
|
||||
// on subscribe (so clients can bootstrap their cache without waiting for the next
|
||||
// deploy), then emits one event each time the gateway's hierarchy cache observes
|
||||
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
|
||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||
// older events because the client was too slow.
|
||||
rpc WatchDeployEvents(WatchDeployEventsRequest) returns (stream DeployEvent);
|
||||
|
||||
// Returns the direct children of a parent object (or the root objects when
|
||||
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
|
||||
// one level at a time instead of paging the full hierarchy. Filters mirror
|
||||
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
|
||||
rpc BrowseChildren(BrowseChildrenRequest) returns (BrowseChildrenReply);
|
||||
}
|
||||
|
||||
message TestConnectionRequest {}
|
||||
|
||||
message TestConnectionReply {
|
||||
bool ok = 1;
|
||||
}
|
||||
|
||||
message GetLastDeployTimeRequest {}
|
||||
|
||||
message GetLastDeployTimeReply {
|
||||
bool present = 1;
|
||||
google.protobuf.Timestamp time_of_last_deploy = 2;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyRequest {
|
||||
// Maximum number of objects to return. The server applies its default when
|
||||
// unset and rejects non-positive values.
|
||||
int32 page_size = 1;
|
||||
// Opaque token returned by a previous DiscoverHierarchy response.
|
||||
string page_token = 2;
|
||||
// Optional. When set, return only this object and its descendants.
|
||||
// Empty = full hierarchy.
|
||||
oneof root {
|
||||
int32 root_gobject_id = 3;
|
||||
string root_tag_name = 4;
|
||||
string root_contained_path = 5;
|
||||
}
|
||||
// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||
// Unset means unlimited depth.
|
||||
google.protobuf.Int32Value max_depth = 6;
|
||||
// Optional object category id filters.
|
||||
repeated int32 category_ids = 7;
|
||||
// Optional case-insensitive substring filters against template names.
|
||||
repeated string template_chain_contains = 8;
|
||||
// Optional anchored, case-insensitive glob over object tag_name.
|
||||
string tag_name_glob = 9;
|
||||
// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||
optional bool include_attributes = 10;
|
||||
// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||
bool alarm_bearing_only = 11;
|
||||
// Optional. Return only objects with at least one historized attribute.
|
||||
bool historized_only = 12;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyReply {
|
||||
repeated GalaxyObject objects = 1;
|
||||
// Non-empty when another page is available.
|
||||
string next_page_token = 2;
|
||||
// Total number of objects in the cached hierarchy at the time of the call.
|
||||
int32 total_object_count = 3;
|
||||
}
|
||||
|
||||
message WatchDeployEventsRequest {
|
||||
// Optional. When set, the bootstrap event is suppressed if the cached deploy
|
||||
// time matches this value. Future events are still emitted normally.
|
||||
google.protobuf.Timestamp last_seen_deploy_time = 1;
|
||||
}
|
||||
|
||||
message DeployEvent {
|
||||
// Monotonically increasing per server start. Gaps indicate dropped events.
|
||||
uint64 sequence = 1;
|
||||
// Server wall-clock when the cache observed the deploy.
|
||||
google.protobuf.Timestamp observed_at = 2;
|
||||
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
|
||||
google.protobuf.Timestamp time_of_last_deploy = 3;
|
||||
bool time_of_last_deploy_present = 4;
|
||||
int32 object_count = 5;
|
||||
int32 attribute_count = 6;
|
||||
}
|
||||
|
||||
message GalaxyObject {
|
||||
int32 gobject_id = 1;
|
||||
string tag_name = 2;
|
||||
string contained_name = 3;
|
||||
string browse_name = 4;
|
||||
int32 parent_gobject_id = 5;
|
||||
bool is_area = 6;
|
||||
int32 category_id = 7;
|
||||
int32 hosted_by_gobject_id = 8;
|
||||
repeated string template_chain = 9;
|
||||
repeated GalaxyAttribute attributes = 10;
|
||||
}
|
||||
|
||||
message GalaxyAttribute {
|
||||
string attribute_name = 1;
|
||||
string full_tag_reference = 2;
|
||||
// Raw Galaxy SQL `dbo.data_type` identifier, passed through unchanged.
|
||||
// This is NOT a member of `mxaccess_gateway.v1.MxDataType` — Galaxy's
|
||||
// type enumeration is distinct from MXAccess's wire data-type enum and
|
||||
// the two must not be cast or compared. The GalaxyRepository service is
|
||||
// metadata-only and deliberately does not share types with
|
||||
// mxaccess_gateway.proto. See docs/GalaxyRepository.md.
|
||||
int32 mx_data_type = 3;
|
||||
// Human-readable name from Galaxy's `dbo.data_type` table (e.g. "Float",
|
||||
// "Integer", "Boolean"). Free-form Galaxy text; not a stable enum.
|
||||
string data_type_name = 4;
|
||||
bool is_array = 5;
|
||||
int32 array_dimension = 6;
|
||||
bool array_dimension_present = 7;
|
||||
// Raw Galaxy SQL attribute-category identifier, passed through unchanged.
|
||||
// Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
int32 mx_attribute_category = 8;
|
||||
// Raw Galaxy SQL security-classification identifier, passed through
|
||||
// unchanged. Galaxy-specific; not mapped to any gateway enum. See
|
||||
// docs/GalaxyRepository.md.
|
||||
int32 security_classification = 9;
|
||||
bool is_historized = 10;
|
||||
bool is_alarm = 11;
|
||||
}
|
||||
|
||||
message BrowseChildrenRequest {
|
||||
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
|
||||
oneof parent {
|
||||
int32 parent_gobject_id = 1;
|
||||
string parent_tag_name = 2;
|
||||
string parent_contained_path = 3;
|
||||
}
|
||||
|
||||
// Maximum number of direct children to return. Server default 500; cap 5000.
|
||||
int32 page_size = 4;
|
||||
// Opaque token returned by a previous BrowseChildren response. Bound to the
|
||||
// cache sequence, parent selector, and the filter set; a mismatch returns
|
||||
// InvalidArgument.
|
||||
string page_token = 5;
|
||||
|
||||
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
|
||||
repeated int32 category_ids = 6;
|
||||
repeated string template_chain_contains = 7;
|
||||
string tag_name_glob = 8;
|
||||
optional bool include_attributes = 9;
|
||||
bool alarm_bearing_only = 10;
|
||||
bool historized_only = 11;
|
||||
}
|
||||
|
||||
message BrowseChildrenReply {
|
||||
// Direct children matching the filter, sorted areas-first then by
|
||||
// case-insensitive display name (same order as the dashboard tree).
|
||||
repeated GalaxyObject children = 1;
|
||||
// Non-empty when another page of siblings is available.
|
||||
string next_page_token = 2;
|
||||
// Total matching direct children of the parent (post-filter).
|
||||
int32 total_child_count = 3;
|
||||
// Parallel array, indexed with `children`. True when the child has at least
|
||||
// one matching descendant under the same filter set. Lets a UI choose
|
||||
// whether to draw an expand triangle without an extra round trip.
|
||||
repeated bool child_has_children = 4;
|
||||
// Cache sequence this reply was projected from. Clients may pass it back as
|
||||
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
|
||||
uint64 cache_sequence = 5;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.GalaxyRepository</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Read-only Galaxy object-hierarchy browse library for the ZB.MOM.WW SCADA family. Provides a SQL provider for the Galaxy Repository database and a canonical gRPC service for exposing the hierarchy to modern .NET 10 clients — extracted from MxAccessGateway so any consumer can browse the Galaxy without loading 32-bit COM.</Description>
|
||||
<PackageTags>galaxy;repository;browse;aveva;wonderware;system-platform;scada;grpc;sql;zb-mom-ww</PackageTags>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-galaxyrepository</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-galaxyrepository</RepositoryUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
<PackageReference Include="Grpc.AspNetCore" />
|
||||
<PackageReference Include="Google.Protobuf" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Grpc.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Proto files are added in Task 2; the empty glob is intentional and builds cleanly. -->
|
||||
<ItemGroup>
|
||||
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.GalaxyRepository.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IGalaxyRepository"/> returning canned rowsets. Counts the heavy
|
||||
/// hierarchy/attribute reads so tests can assert deploy-gated skips, and can be flipped to
|
||||
/// throw so the failure path is exercisable.
|
||||
/// </summary>
|
||||
internal sealed class FakeGalaxyRepository : IGalaxyRepository
|
||||
{
|
||||
private readonly IReadOnlyList<GalaxyHierarchyRow> _hierarchy;
|
||||
private readonly IReadOnlyList<GalaxyAttributeRow> _attributes;
|
||||
private readonly IReadOnlyList<GalaxyAlarmAttributeRow> _alarmAttributes;
|
||||
|
||||
public FakeGalaxyRepository(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||
DateTime? deployTime,
|
||||
IReadOnlyList<GalaxyAlarmAttributeRow>? alarmAttributes = null)
|
||||
{
|
||||
_hierarchy = hierarchy;
|
||||
_attributes = attributes;
|
||||
_alarmAttributes = alarmAttributes ?? Array.Empty<GalaxyAlarmAttributeRow>();
|
||||
DeployTime = deployTime;
|
||||
}
|
||||
|
||||
/// <summary>The deploy time returned by <see cref="GetLastDeployTimeAsync"/>; mutate to simulate a redeploy.</summary>
|
||||
public DateTime? DeployTime { get; set; }
|
||||
|
||||
/// <summary>When set, every query throws this exception (simulates an unreachable database).</summary>
|
||||
public Exception? ThrowOnQuery { get; set; }
|
||||
|
||||
public int HierarchyReadCount { get; private set; }
|
||||
|
||||
public int AttributeReadCount { get; private set; }
|
||||
|
||||
public int AlarmAttributeReadCount { get; private set; }
|
||||
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) =>
|
||||
ThrowOnQuery is null ? Task.FromResult(true) : throw ThrowOnQuery;
|
||||
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnQuery is not null)
|
||||
{
|
||||
throw ThrowOnQuery;
|
||||
}
|
||||
|
||||
return Task.FromResult(DeployTime);
|
||||
}
|
||||
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnQuery is not null)
|
||||
{
|
||||
throw ThrowOnQuery;
|
||||
}
|
||||
|
||||
HierarchyReadCount++;
|
||||
return Task.FromResult(_hierarchy.ToList());
|
||||
}
|
||||
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnQuery is not null)
|
||||
{
|
||||
throw ThrowOnQuery;
|
||||
}
|
||||
|
||||
AttributeReadCount++;
|
||||
return Task.FromResult(_attributes.ToList());
|
||||
}
|
||||
|
||||
public Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnQuery is not null)
|
||||
{
|
||||
throw ThrowOnQuery;
|
||||
}
|
||||
|
||||
AlarmAttributeReadCount++;
|
||||
return Task.FromResult(_alarmAttributes.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records published deploy events so tests can assert publication.</summary>
|
||||
internal sealed class RecordingDeployNotifier : IGalaxyDeployNotifier
|
||||
{
|
||||
public List<GalaxyDeployEventInfo> Published { get; } = [];
|
||||
|
||||
public GalaxyDeployEventInfo? Latest { get; private set; }
|
||||
|
||||
public void Publish(GalaxyDeployEventInfo info)
|
||||
{
|
||||
Published.Add(info);
|
||||
Latest = info;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (Latest is { } latest)
|
||||
{
|
||||
yield return latest;
|
||||
}
|
||||
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IGalaxyHierarchySnapshotStore"/>. Pre-seed <see cref="Snapshot"/>
|
||||
/// to exercise the restore path; reads <see cref="SaveAsync"/> back to assert persistence.
|
||||
/// </summary>
|
||||
internal sealed class FakeSnapshotStore : IGalaxyHierarchySnapshotStore
|
||||
{
|
||||
public GalaxyHierarchySnapshot? Snapshot { get; set; }
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public int LoadCount { get; private set; }
|
||||
|
||||
public Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
SaveCount++;
|
||||
Snapshot = snapshot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
LoadCount++;
|
||||
return Task.FromResult(Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TimeProvider"/> whose UTC clock is fixed (and advanceable) so the cache's
|
||||
/// staleness projection (which fires after a 5-minute threshold) is deterministic.
|
||||
/// </summary>
|
||||
internal sealed class StubTimeProvider(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapper tests for <see cref="GalaxyRepository.MapAlarmRow"/>. These assert the
|
||||
/// FullTagReference / SourceObjectReference derivation produced by
|
||||
/// <c>AlarmAttributesSql</c> without touching a database: the SQL projects
|
||||
/// <c>tag_name</c> as the source object and <c>tag_name + '.' + attribute_name</c> as
|
||||
/// the full reference, exactly as <c>AttributesSql</c> does.
|
||||
/// </summary>
|
||||
public sealed class GalaxyAlarmAttributeMappingTests
|
||||
{
|
||||
/// <summary>Verifies the mapper copies all projected columns onto the row.</summary>
|
||||
[Fact]
|
||||
public void MapAlarmRow_CopiesProjectedColumns()
|
||||
{
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||
fullTagReference: "Tank01.Level.HiHi",
|
||||
sourceObjectReference: "Tank01",
|
||||
area: "TestArea");
|
||||
|
||||
Assert.Equal("Tank01.Level.HiHi", row.FullTagReference);
|
||||
Assert.Equal("Tank01", row.SourceObjectReference);
|
||||
Assert.Equal("TestArea", row.Area);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is always empty:
|
||||
/// the schema does not expose an ack-comment address, so the watch-list resolver
|
||||
/// composes it later from configuration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MapAlarmRow_LeavesAckCommentSubtagEmpty()
|
||||
{
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(
|
||||
fullTagReference: "Tank01.Level.HiHi",
|
||||
sourceObjectReference: "Tank01",
|
||||
area: "TestArea");
|
||||
|
||||
Assert.Equal(string.Empty, row.AckCommentSubtag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the SourceObjectReference is the owning object (the SQL <c>tag_name</c>),
|
||||
/// i.e. the segment that precedes the first attribute dot in the full reference, even
|
||||
/// when the attribute itself is a multi-segment extension path.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("Tank01", "Level.HiHi", "Tank01.Level.HiHi")]
|
||||
[InlineData("Pump_001", "Speed", "Pump_001.Speed")]
|
||||
[InlineData("TestAlarm001", "Alarm001", "TestAlarm001.Alarm001")]
|
||||
public void MapAlarmRow_SourceObjectIsSegmentBeforeFirstAttributeDot(
|
||||
string tagName,
|
||||
string attributeName,
|
||||
string expectedFullReference)
|
||||
{
|
||||
// Mirror the AlarmAttributesSql projection: full_tag_reference = tag_name + '.' + attribute_name.
|
||||
string fullTagReference = tagName + "." + attributeName;
|
||||
|
||||
GalaxyAlarmAttributeRow row = GalaxyRepository.MapAlarmRow(fullTagReference, tagName, area: "TestArea");
|
||||
|
||||
Assert.Equal(expectedFullReference, row.FullTagReference);
|
||||
Assert.Equal(tagName, row.SourceObjectReference);
|
||||
Assert.Equal("TestArea", row.Area);
|
||||
Assert.Equal(row.FullTagReference, row.SourceObjectReference + "." + attributeName);
|
||||
}
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Direct coverage for <see cref="GalaxyBrowseProjector"/>. Validates parent
|
||||
/// resolution (gobject id / tag name / contained path), paging across siblings,
|
||||
/// filter parity with <see cref="GalaxyHierarchyProjector"/>, the
|
||||
/// <c>child_has_children</c> hint, browse-subtree constraints, and the
|
||||
/// attribute-skeleton mode.
|
||||
/// </summary>
|
||||
public sealed class GalaxyBrowseProjectorTests
|
||||
{
|
||||
/// <summary>Verifies that an empty parent oneof returns the root area.</summary>
|
||||
[Fact]
|
||||
public void Project_NoParent_ReturnsRootArea()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest(),
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant", result.Children[0].TagName);
|
||||
Assert.True(result.ChildHasChildren[0]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by gobject id returns sorted direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentGobjectId_ReturnsDirectChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
Assert.Equal(new[] { true, false, false, false }, result.ChildHasChildren.ToArray());
|
||||
Assert.Equal(4, result.TotalChildCount);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by tag name returns the same direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentTagName_ResolvesParent()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentTagName = "Plant" },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that resolving the parent by contained path returns the same direct children.</summary>
|
||||
[Fact]
|
||||
public void Project_ByParentContainedPath_ResolvesParent()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentContainedPath = "Plant" },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Line_A", "Plant.Mixer_001", "Plant.Mixer_002", "Plant.Pump_001" }, names);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an unknown parent gobject id throws an RpcException with StatusCode.NotFound.</summary>
|
||||
[Fact]
|
||||
public void Project_UnknownParent_ThrowsNotFound()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
RpcException exception = Assert.Throws<RpcException>(() => GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 999 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10));
|
||||
|
||||
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that paging across siblings returns every sibling exactly once.</summary>
|
||||
[Fact]
|
||||
public void Project_PagedAcrossSiblings_ReturnsEverySiblingOnce()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult first = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 2);
|
||||
GalaxyBrowseChildrenResult second = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 2,
|
||||
pageSize: 2);
|
||||
|
||||
List<string> collected = first.Children
|
||||
.Concat(second.Children)
|
||||
.Select(child => child.TagName)
|
||||
.ToList();
|
||||
Assert.Equal(4, collected.Count);
|
||||
Assert.Equal(collected.Count, collected.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.Equal(
|
||||
new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"Plant.Line_A",
|
||||
"Plant.Mixer_001",
|
||||
"Plant.Mixer_002",
|
||||
"Plant.Pump_001",
|
||||
},
|
||||
new HashSet<string>(collected, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a tag-name glob filters direct children and clears the has-children hint.</summary>
|
||||
[Fact]
|
||||
public void Project_TagNameGlobFiltersChildren_AndUpdatesHasChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
TagNameGlob = "*Mixer*",
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
string[] names = result.Children.Select(child => child.TagName).ToArray();
|
||||
Assert.Equal(new[] { "Plant.Mixer_001", "Plant.Mixer_002" }, names);
|
||||
Assert.Equal(new[] { false, false }, result.ChildHasChildren.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that historized-only filtering also drives the has-children hint via descendants.</summary>
|
||||
[Fact]
|
||||
public void Project_HistorizedOnlyFiltersDescendants_HasChildrenReflectsFilter()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
HistorizedOnly = true,
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
// Line_A itself has no historized attributes, but its descendant Sensor_A1 does,
|
||||
// so the subtree match keeps Line_A in the result with has-children = true.
|
||||
// Mixer_001/Mixer_002/Pump_001 have no historized attributes themselves and
|
||||
// no historized descendants -> filtered out entirely.
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
Assert.Equal(new[] { true }, result.ChildHasChildren.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that <c>IncludeAttributes=false</c> returns object skeletons.</summary>
|
||||
[Fact]
|
||||
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest
|
||||
{
|
||||
ParentGobjectId = 1,
|
||||
IncludeAttributes = false,
|
||||
},
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
GalaxyObject mixer = result.Children.Single(child => child.TagName == "Plant.Mixer_001");
|
||||
Assert.Empty(mixer.Attributes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that browse-subtree globs constrain the returned children.</summary>
|
||||
[Fact]
|
||||
public void Project_BrowseSubtrees_ExcludesChildrenOutsideAllowedGlobs()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = CreateEntry();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 1 },
|
||||
browseSubtreeGlobs: new[] { "Plant/Line_*" },
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Single(result.Children);
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
|
||||
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
|
||||
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
|
||||
/// 5-second xUnit timeout asserts termination.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5000)]
|
||||
public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
|
||||
{
|
||||
await Task.Yield();
|
||||
// Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
|
||||
// points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
|
||||
// [12] = [A], [10] = [B], [11] = [C].
|
||||
// None of them are historized, so HistorizedOnly=true forces the projector to
|
||||
// call HasMatchingDescendant on every direct child, exercising the cycle walk.
|
||||
GalaxyObject a = new()
|
||||
{
|
||||
GobjectId = 10,
|
||||
ParentGobjectId = 12,
|
||||
ContainedName = "A",
|
||||
BrowseName = "A",
|
||||
TagName = "A",
|
||||
};
|
||||
GalaxyObject b = new()
|
||||
{
|
||||
GobjectId = 11,
|
||||
ParentGobjectId = 10,
|
||||
ContainedName = "B",
|
||||
BrowseName = "B",
|
||||
TagName = "B",
|
||||
};
|
||||
GalaxyObject c = new()
|
||||
{
|
||||
GobjectId = 12,
|
||||
ParentGobjectId = 11,
|
||||
ContainedName = "C",
|
||||
BrowseName = "C",
|
||||
TagName = "C",
|
||||
};
|
||||
|
||||
IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
|
||||
// Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
|
||||
// projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
|
||||
// without the visited-id guard. With the guard, the walk terminates and returns
|
||||
// an empty page (no historized descendants exist anywhere in the cycle).
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Empty(result.Children);
|
||||
Assert.Equal(0, result.TotalChildCount);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = CreateObjects();
|
||||
return GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> CreateObjects()
|
||||
{
|
||||
GalaxyObject plant = new()
|
||||
{
|
||||
GobjectId = 1,
|
||||
ParentGobjectId = 0,
|
||||
IsArea = true,
|
||||
ContainedName = "Plant",
|
||||
BrowseName = "Plant",
|
||||
TagName = "Plant",
|
||||
};
|
||||
GalaxyObject mixer001 = new()
|
||||
{
|
||||
GobjectId = 2,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Mixer_001",
|
||||
BrowseName = "Mixer_001",
|
||||
TagName = "Plant.Mixer_001",
|
||||
};
|
||||
mixer001.Attributes.Add(new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Speed",
|
||||
FullTagReference = "Plant.Mixer_001.Speed",
|
||||
});
|
||||
GalaxyObject mixer002 = new()
|
||||
{
|
||||
GobjectId = 3,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Mixer_002",
|
||||
BrowseName = "Mixer_002",
|
||||
TagName = "Plant.Mixer_002",
|
||||
};
|
||||
GalaxyObject lineA = new()
|
||||
{
|
||||
GobjectId = 4,
|
||||
ParentGobjectId = 1,
|
||||
IsArea = true,
|
||||
ContainedName = "Line_A",
|
||||
BrowseName = "Line_A",
|
||||
TagName = "Plant.Line_A",
|
||||
};
|
||||
GalaxyObject sensorA1 = new()
|
||||
{
|
||||
GobjectId = 5,
|
||||
ParentGobjectId = 4,
|
||||
ContainedName = "Sensor_A1",
|
||||
BrowseName = "Sensor_A1",
|
||||
TagName = "Plant.Line_A.Sensor_A1",
|
||||
};
|
||||
sensorA1.Attributes.Add(new GalaxyAttribute
|
||||
{
|
||||
AttributeName = "Value",
|
||||
FullTagReference = "Plant.Line_A.Sensor_A1.Value",
|
||||
IsHistorized = true,
|
||||
});
|
||||
GalaxyObject pump001 = new()
|
||||
{
|
||||
GobjectId = 6,
|
||||
ParentGobjectId = 1,
|
||||
ContainedName = "Pump_001",
|
||||
BrowseName = "Pump_001",
|
||||
TagName = "Plant.Pump_001",
|
||||
};
|
||||
|
||||
return new[] { plant, mixer001, mixer002, lineA, sensorA1, pump001 };
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
public sealed class GalaxyDeployNotifierTests
|
||||
{
|
||||
/// <summary>Verifies that a subscriber blocks until a deploy event is published.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
|
||||
ValueTask<bool> moveNext = enumerator.MoveNextAsync();
|
||||
Assert.False(moveNext.IsCompleted);
|
||||
|
||||
GalaxyDeployEventInfo published = new(
|
||||
Sequence: 1,
|
||||
ObservedAt: DateTimeOffset.UtcNow,
|
||||
TimeOfLastDeploy: DateTimeOffset.UtcNow,
|
||||
ObjectCount: 5,
|
||||
AttributeCount: 25);
|
||||
notifier.Publish(published);
|
||||
|
||||
Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||
Assert.Same(published, enumerator.Current);
|
||||
|
||||
await cts.CancelAsync();
|
||||
await enumerator.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a subscriber immediately receives a cached latest deploy event.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9);
|
||||
notifier.Publish(first);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||
Assert.Same(first, enumerator.Current);
|
||||
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that published events fan out to all active subscribers.</summary>
|
||||
[Fact]
|
||||
public async Task Publish_FansOutToAllSubscribers()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> a = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
await using IAsyncEnumerator<GalaxyDeployEventInfo> b = notifier
|
||||
.SubscribeAsync(cts.Token)
|
||||
.GetAsyncEnumerator(cts.Token);
|
||||
|
||||
GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0);
|
||||
notifier.Publish(info);
|
||||
|
||||
Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||
Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||
Assert.Same(info, a.Current);
|
||||
Assert.Same(info, b.Current);
|
||||
|
||||
await cts.CancelAsync();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the Latest property tracks the most recently published event.</summary>
|
||||
[Fact]
|
||||
public void Latest_TracksMostRecentPublish()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
Assert.Null(notifier.Latest);
|
||||
|
||||
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0);
|
||||
GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0);
|
||||
notifier.Publish(first);
|
||||
notifier.Publish(second);
|
||||
|
||||
Assert.Same(second, notifier.Latest);
|
||||
}
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="GalaxyHierarchyCache"/> first-load, deploy-gating, snapshot
|
||||
/// restore, persistence, and status-transition behavior. Uses an in-memory
|
||||
/// <see cref="IGalaxyRepository"/> and snapshot store plus a fixed
|
||||
/// <see cref="StubTimeProvider"/> so no SQL is touched and no asserts are time-sensitive.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyCacheTests
|
||||
{
|
||||
private static readonly DateTimeOffset FixedNow = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||
private static readonly DateTime DeployTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static List<GalaxyHierarchyRow> SampleHierarchy() =>
|
||||
[
|
||||
new() { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", BrowseName = "Area1", IsArea = true },
|
||||
new() { GobjectId = 2, TagName = "Pump01", ContainedName = "Pump01", BrowseName = "Pump01", ParentGobjectId = 1 },
|
||||
];
|
||||
|
||||
private static List<GalaxyAttributeRow> SampleAttributes() =>
|
||||
[
|
||||
new() { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true, IsAlarm = true },
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_FirstLoad_PopulatesCurrentWithDataAndUnblocksWaitForFirstLoad()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
RecordingDeployNotifier notifier = new();
|
||||
using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
|
||||
|
||||
// Before refresh, the gate is unset and there is no data.
|
||||
Assert.False(cache.Current.HasData);
|
||||
Assert.Equal(GalaxyCacheStatus.Unknown, cache.Current.Status);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
// First load completes (does not hang) and Current now holds usable data.
|
||||
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||
GalaxyHierarchyCacheEntry current = cache.Current;
|
||||
Assert.True(current.HasData);
|
||||
Assert.Equal(GalaxyCacheStatus.Healthy, current.Status);
|
||||
Assert.Equal(2, current.ObjectCount);
|
||||
Assert.Equal(1, current.AreaCount);
|
||||
Assert.Equal(1, current.AttributeCount);
|
||||
Assert.Equal(1, current.HistorizedAttributeCount);
|
||||
Assert.Equal(1, current.AlarmAttributeCount);
|
||||
|
||||
// The heavy queries ran exactly once and a deploy event was published.
|
||||
Assert.Equal(1, repository.HierarchyReadCount);
|
||||
Assert.Equal(1, repository.AttributeReadCount);
|
||||
GalaxyDeployEventInfo published = Assert.Single(notifier.Published);
|
||||
Assert.Equal(2, published.ObjectCount);
|
||||
Assert.Equal(1, published.AttributeCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_NoDeployChange_SkipsHeavyQueriesOnSecondRefresh()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
// Deploy time unchanged => the heavy hierarchy/attribute reads happened only once.
|
||||
Assert.Equal(1, repository.HierarchyReadCount);
|
||||
Assert.Equal(1, repository.AttributeReadCount);
|
||||
Assert.True(cache.Current.HasData);
|
||||
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_DeployAdvances_RebuildsAndBumpsSequence()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
RecordingDeployNotifier notifier = new();
|
||||
using GalaxyHierarchyCache cache = new(repository, notifier, new StubTimeProvider(FixedNow));
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
long firstSequence = cache.Current.Sequence;
|
||||
|
||||
repository.DeployTime = DeployTime.AddHours(1);
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, repository.HierarchyReadCount);
|
||||
Assert.Equal(firstSequence + 1, cache.Current.Sequence);
|
||||
Assert.Equal(2, notifier.Published.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_FirstQueryFailsNoPriorData_StatusUnavailableButFirstLoadStillCompletes()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
|
||||
{
|
||||
ThrowOnQuery = new TimeoutException("galaxy db unreachable"),
|
||||
};
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
// First load must complete so callers do not hang, even though the query failed.
|
||||
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||
Assert.False(cache.Current.HasData);
|
||||
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||
Assert.Contains("unreachable", cache.Current.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_QueryFailsAfterPriorData_DegradesToStaleAndKeepsData()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
Assert.True(cache.Current.HasData);
|
||||
|
||||
// A later refresh fails: data is retained but flagged Stale.
|
||||
repository.DeployTime = DeployTime.AddHours(1);
|
||||
repository.ThrowOnQuery = new InvalidOperationException("transient");
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(cache.Current.HasData);
|
||||
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||
Assert.Equal(2, cache.Current.ObjectCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Current_AfterStalenessThreshold_ProjectsHealthyToStale()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
StubTimeProvider clock = new(FixedNow);
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), clock);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||
|
||||
// Advance past the 5-minute staleness threshold with no successful refresh.
|
||||
clock.Advance(TimeSpan.FromMinutes(6));
|
||||
|
||||
Assert.Equal(GalaxyCacheStatus.Stale, cache.Current.Status);
|
||||
// Data is still present — Stale means "old", not "gone".
|
||||
Assert.True(cache.Current.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_PersistsSnapshotAfterSuccessfulHeavyRefresh()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
FakeSnapshotStore store = new();
|
||||
using GalaxyHierarchyCache cache = new(
|
||||
repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.NotNull(store.Snapshot);
|
||||
Assert.Equal(2, store.Snapshot!.Hierarchy.Count);
|
||||
Assert.Single(store.Snapshot.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_SnapshotRestore_ServesLastKnownDataAsStaleWhenDatabaseUnreachable()
|
||||
{
|
||||
// The snapshot store already holds a persisted dataset (last-known browse data).
|
||||
FakeSnapshotStore store = new()
|
||||
{
|
||||
Snapshot = new GalaxyHierarchySnapshot(
|
||||
LastDeployTime: DeployTime,
|
||||
SavedAt: FixedNow.AddMinutes(-1),
|
||||
Hierarchy: SampleHierarchy(),
|
||||
Attributes: SampleAttributes()),
|
||||
};
|
||||
|
||||
// The Galaxy database is unreachable on this cold start.
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime)
|
||||
{
|
||||
ThrowOnQuery = new TimeoutException("cold start, db down"),
|
||||
};
|
||||
RecordingDeployNotifier notifier = new();
|
||||
using GalaxyHierarchyCache cache = new(
|
||||
repository, notifier, new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
// First load is satisfied by the restored snapshot, not by SQL.
|
||||
await cache.WaitForFirstLoadAsync(new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token);
|
||||
Assert.Equal(1, store.LoadCount);
|
||||
GalaxyHierarchyCacheEntry current = cache.Current;
|
||||
Assert.True(current.HasData);
|
||||
// Restored data is "last-known", surfaced as Stale until the live DB confirms.
|
||||
Assert.Equal(GalaxyCacheStatus.Stale, current.Status);
|
||||
Assert.Equal(2, current.ObjectCount);
|
||||
Assert.Equal(DeployTime, current.LastDeployTime!.Value.UtcDateTime);
|
||||
|
||||
// A deploy event was published for the restored data.
|
||||
Assert.Single(notifier.Published);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshAsync_SnapshotRestoreThenLiveQuery_PromotesRestoredDataToHealthy()
|
||||
{
|
||||
FakeSnapshotStore store = new()
|
||||
{
|
||||
Snapshot = new GalaxyHierarchySnapshot(
|
||||
LastDeployTime: DeployTime,
|
||||
SavedAt: FixedNow.AddMinutes(-1),
|
||||
Hierarchy: SampleHierarchy(),
|
||||
Attributes: SampleAttributes()),
|
||||
};
|
||||
// DB is reachable and reports the SAME deploy time the snapshot was pulled at.
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
using GalaxyHierarchyCache cache = new(
|
||||
repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow), logger: null, snapshotStore: store);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
// Restore seeds Stale data; the same-deploy live query promotes it to Healthy
|
||||
// without re-running the heavy hierarchy/attribute reads.
|
||||
Assert.Equal(GalaxyCacheStatus.Healthy, cache.Current.Status);
|
||||
Assert.Equal(0, repository.HierarchyReadCount);
|
||||
Assert.True(cache.Current.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_CanBeCalledWithoutHavingRefreshed()
|
||||
{
|
||||
FakeGalaxyRepository repository = new(SampleHierarchy(), SampleAttributes(), DeployTime);
|
||||
GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier(), new StubTimeProvider(FixedNow));
|
||||
|
||||
// Dispose must be safe even when no refresh ever ran (semaphore never entered).
|
||||
cache.Dispose();
|
||||
}
|
||||
}
|
||||
+458
@@ -0,0 +1,458 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-logic tests for <see cref="GalaxyHierarchyProjector"/> and
|
||||
/// <see cref="GalaxyBrowseProjector"/>. No SQL: the cache entry under test is built
|
||||
/// from a small hand-made hierarchy through the same materialization the live cache
|
||||
/// uses (a fake <see cref="IGalaxyRepository"/> driven through
|
||||
/// <see cref="GalaxyHierarchyCache.RefreshAsync"/>), so the projectors are exercised
|
||||
/// against a real <see cref="GalaxyHierarchyIndex"/>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyProjectorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a realistic cache entry by driving a fake repository through the cache's
|
||||
/// own refresh path. This goes through <c>BuildEntry</c> + <see cref="GalaxyHierarchyIndex.Build"/>
|
||||
/// exactly as production does, rather than reaching for an internal factory.
|
||||
/// </summary>
|
||||
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
FakeGalaxyRepository repository = new(hierarchy, attributes, deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier());
|
||||
cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
Assert.True(entry.HasData);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// A small but representative galaxy:
|
||||
// PlantArea (area, id 1)
|
||||
// ├─ LineA (area, id 2)
|
||||
// │ ├─ Pump01 (id 10, template "Pump", historized+alarm attr)
|
||||
// │ └─ Valve01 (id 11, template "Valve", plain attr)
|
||||
// └─ Mixer01 (id 12, template "Mixer", alarm attr only)
|
||||
// StandaloneTank (id 20, no parent — a root object)
|
||||
private static GalaxyHierarchyCacheEntry BuildSampleEntry()
|
||||
{
|
||||
List<GalaxyHierarchyRow> hierarchy =
|
||||
[
|
||||
Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100),
|
||||
Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100),
|
||||
Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump", "$UserDefined"]),
|
||||
Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]),
|
||||
Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]),
|
||||
Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]),
|
||||
];
|
||||
|
||||
List<GalaxyAttributeRow> attributes =
|
||||
[
|
||||
// Pump01: historized AND alarm-bearing.
|
||||
Attribute(10, "Pump01.PV", historized: true, alarm: true),
|
||||
Attribute(10, "Pump01.SP", historized: false, alarm: false),
|
||||
// Valve01: plain.
|
||||
Attribute(11, "Valve01.Cmd", historized: false, alarm: false),
|
||||
// Mixer01: alarm only.
|
||||
Attribute(12, "Mixer01.Fault", historized: false, alarm: true),
|
||||
// StandaloneTank: historized only.
|
||||
Attribute(20, "StandaloneTank.Level", historized: true, alarm: false),
|
||||
];
|
||||
|
||||
return BuildEntry(hierarchy, attributes);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyRow Hierarchy(
|
||||
int id,
|
||||
string tagName,
|
||||
int parent,
|
||||
bool isArea = false,
|
||||
int category = 0,
|
||||
IReadOnlyList<string>? templates = null) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = tagName,
|
||||
ContainedName = tagName,
|
||||
BrowseName = tagName,
|
||||
ParentGobjectId = parent,
|
||||
IsArea = isArea,
|
||||
CategoryId = category,
|
||||
TemplateChain = templates ?? Array.Empty<string>(),
|
||||
};
|
||||
|
||||
private static GalaxyAttributeRow Attribute(
|
||||
int gobjectId,
|
||||
string fullTagReference,
|
||||
bool historized,
|
||||
bool alarm) => new()
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
AttributeName = fullTagReference.Split('.')[^1],
|
||||
FullTagReference = fullTagReference,
|
||||
IsHistorized = historized,
|
||||
IsAlarm = alarm,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Project_NoFilters_ReturnsEveryObject()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest());
|
||||
|
||||
Assert.Equal(6, result.TotalObjectCount);
|
||||
Assert.Equal(6, result.Objects.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_PageSizeAndOffset_SlicesTheOrderedResult()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new();
|
||||
|
||||
GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: int.MaxValue);
|
||||
GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 2);
|
||||
GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 2, pageSize: 2);
|
||||
GalaxyHierarchyQueryResult page3 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 4, pageSize: 2);
|
||||
|
||||
// Total is unaffected by paging.
|
||||
Assert.Equal(6, page1.TotalObjectCount);
|
||||
Assert.Equal(2, page1.Objects.Count);
|
||||
Assert.Equal(2, page2.Objects.Count);
|
||||
Assert.Equal(2, page3.Objects.Count);
|
||||
|
||||
// The three pages reconstruct the full ordered result with no gaps/dupes.
|
||||
List<int> paged =
|
||||
[
|
||||
.. page1.Objects.Select(o => o.GobjectId),
|
||||
.. page2.Objects.Select(o => o.GobjectId),
|
||||
.. page3.Objects.Select(o => o.GobjectId),
|
||||
];
|
||||
Assert.Equal(full.Objects.Select(o => o.GobjectId), paged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_OffsetPastEnd_ReturnsEmptyPageButRealTotal()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(
|
||||
entry, new DiscoverHierarchyRequest(), browseSubtreeGlobs: null, offset: 999, pageSize: 10);
|
||||
|
||||
Assert.Empty(result.Objects);
|
||||
Assert.Equal(6, result.TotalObjectCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_PageSignature_IsStableAcrossPagesAndMatchesComputeFilterSignature()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };
|
||||
|
||||
string expected = GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtreeGlobs: null);
|
||||
GalaxyHierarchyQueryResult page1 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 1);
|
||||
GalaxyHierarchyQueryResult page2 = GalaxyHierarchyProjector.Project(entry, request, browseSubtreeGlobs: null, offset: 1, pageSize: 1);
|
||||
|
||||
// The signature a caller computes to mint a page token round-trips: the projector
|
||||
// reports the same signature on every page of the same filter set.
|
||||
Assert.Equal(expected, page1.FilterSignature);
|
||||
Assert.Equal(expected, page2.FilterSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeFilterSignature_DiffersWhenAnyFilterChanges()
|
||||
{
|
||||
DiscoverHierarchyRequest baseRequest = new() { TagNameGlob = "Pump*" };
|
||||
DiscoverHierarchyRequest differentGlob = new() { TagNameGlob = "Valve*" };
|
||||
DiscoverHierarchyRequest differentAlarm = new() { TagNameGlob = "Pump*", AlarmBearingOnly = true };
|
||||
|
||||
string baseSig = GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, null);
|
||||
|
||||
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentGlob, null));
|
||||
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(differentAlarm, null));
|
||||
Assert.NotEqual(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(baseRequest, browseSubtreeGlobs: ["PlantArea/*"]));
|
||||
// Same inputs => same signature (deterministic).
|
||||
Assert.Equal(baseSig, GalaxyHierarchyProjector.ComputeFilterSignature(new DiscoverHierarchyRequest { TagNameGlob = "Pump*" }, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MaxDepthZero_FromRoot_ReturnsOnlyTheRoot()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 0 };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
GalaxyObject only = Assert.Single(result.Objects);
|
||||
Assert.Equal(1, only.GobjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_MaxDepthOne_FromRoot_ReturnsRootAndDirectChildrenOnly()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
// PlantArea(1) depth 0; LineA(2) and Mixer01(12) depth 1; Pump01/Valve01 depth 2.
|
||||
DiscoverHierarchyRequest request = new() { RootGobjectId = 1, MaxDepth = 1 };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
Assert.Equal([1, 2, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_NegativeMaxDepth_ThrowsInvalidArgument()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { MaxDepth = -1 };
|
||||
|
||||
RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_UnknownRoot_ThrowsNotFound()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { RootGobjectId = 99999 };
|
||||
|
||||
RpcException ex = Assert.Throws<RpcException>(() => GalaxyHierarchyProjector.Project(entry, request));
|
||||
Assert.Equal(StatusCode.NotFound, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_HistorizedOnly_ReturnsOnlyObjectsWithAHistorizedAttribute()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { HistorizedOnly = true };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
// Pump01(10) and StandaloneTank(20) carry historized attributes.
|
||||
Assert.Equal([10, 20], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_AlarmBearingOnly_ReturnsOnlyObjectsWithAnAlarmAttribute()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
// Pump01(10) and Mixer01(12) carry alarm attributes.
|
||||
Assert.Equal([10, 12], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_AlarmAndHistorizedTogether_RequiresBoth()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { AlarmBearingOnly = true, HistorizedOnly = true };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
// Only Pump01(10) carries an attribute set that is both historized and alarm-bearing.
|
||||
GalaxyObject only = Assert.Single(result.Objects);
|
||||
Assert.Equal(10, only.GobjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_TagNameGlob_MatchesAnchoredCaseInsensitive()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
GalaxyHierarchyQueryResult prefix = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });
|
||||
Assert.Equal([10], prefix.Objects.Select(o => o.GobjectId));
|
||||
|
||||
// Case-insensitive.
|
||||
GalaxyHierarchyQueryResult lower = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "pump01" });
|
||||
Assert.Equal([10], lower.Objects.Select(o => o.GobjectId));
|
||||
|
||||
// '?' single-char wildcard: "Pump0?" matches "Pump01".
|
||||
GalaxyHierarchyQueryResult single = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump0?" });
|
||||
Assert.Equal([10], single.Objects.Select(o => o.GobjectId));
|
||||
|
||||
// Anchored: a bare substring that is not a prefix matches nothing.
|
||||
GalaxyHierarchyQueryResult anchored = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "ump01" });
|
||||
Assert.Empty(anchored.Objects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_CategoryIds_FilterByObjectCategory()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { CategoryIds = { 200, 201 } };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
// category 200 = Pump01(10), category 201 = Valve01(11).
|
||||
Assert.Equal([10, 11], result.Objects.Select(o => o.GobjectId).OrderBy(id => id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_TemplateChainContains_IsSubstringAndCaseInsensitive()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { TemplateChainContains = { "pump" } };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
GalaxyObject only = Assert.Single(result.Objects);
|
||||
Assert.Equal(10, only.GobjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_IncludeAttributesDefault_CarriesAttributes()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*" };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
GalaxyObject pump = Assert.Single(result.Objects);
|
||||
Assert.Equal(2, pump.Attributes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_IncludeAttributesFalse_ReturnsSkeletons()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
DiscoverHierarchyRequest request = new() { TagNameGlob = "Pump*", IncludeAttributes = false };
|
||||
|
||||
GalaxyHierarchyQueryResult result = GalaxyHierarchyProjector.Project(entry, request);
|
||||
|
||||
GalaxyObject pump = Assert.Single(result.Objects);
|
||||
Assert.Empty(pump.Attributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_IncludeAttributesFalse_DoesNotMutateTheCachedEntry()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
// Project with attributes stripped, then again with attributes included.
|
||||
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*", IncludeAttributes = false });
|
||||
GalaxyHierarchyQueryResult included = GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest { TagNameGlob = "Pump*" });
|
||||
|
||||
// The earlier strip cloned the object — the cached entry still holds the attributes.
|
||||
GalaxyObject pump = Assert.Single(included.Objects);
|
||||
Assert.Equal(2, pump.Attributes.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Project_InvalidOffsetOrPageSize_Throws()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: -1, pageSize: 10));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
||||
GalaxyHierarchyProjector.Project(entry, new DiscoverHierarchyRequest(), null, offset: 0, pageSize: 0));
|
||||
}
|
||||
|
||||
// ---- GalaxyBrowseProjector ----
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_OfPlantArea_ReturnsDirectChildrenAreasFirst()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
BrowseChildrenRequest request = new() { ParentGobjectId = 1 };
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||
|
||||
// Direct children of PlantArea(1) are LineA(2, area) and Mixer01(12, non-area);
|
||||
// areas sort first.
|
||||
Assert.Equal([2, 12], result.Children.Select(c => c.GobjectId));
|
||||
Assert.Equal(2, result.TotalChildCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_ChildHasChildrenFlag_ReflectsPresenceOfChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
BrowseChildrenRequest request = new() { ParentGobjectId = 1 };
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||
|
||||
Dictionary<int, bool> hasChildren = result.Children
|
||||
.Select((child, index) => (child.GobjectId, result.ChildHasChildren[index]))
|
||||
.ToDictionary(t => t.GobjectId, t => t.Item2);
|
||||
|
||||
// LineA(2) contains Pump01/Valve01 -> true; Mixer01(12) is a leaf -> false.
|
||||
Assert.True(hasChildren[2]);
|
||||
Assert.False(hasChildren[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_OfRoot_ReturnsTopLevelObjects()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
// Empty parent oneof => roots (parent id 0).
|
||||
BrowseChildrenRequest request = new();
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||
|
||||
// Roots: PlantArea(1, area) and StandaloneTank(20, non-area); areas first.
|
||||
Assert.Equal([1, 20], result.Children.Select(c => c.GobjectId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_FilterMatchingDescendant_SurfacesNonMatchingAncestor()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
// Pump01 lives two levels under PlantArea. Browsing PlantArea's children with a
|
||||
// Pump glob should still surface LineA (which itself does not match) because it
|
||||
// contains a matching descendant.
|
||||
BrowseChildrenRequest request = new() { ParentGobjectId = 1, TagNameGlob = "Pump*" };
|
||||
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(entry, request, browseSubtreeGlobs: null, offset: 0, pageSize: 100);
|
||||
|
||||
GalaxyObject surfaced = Assert.Single(result.Children);
|
||||
Assert.Equal(2, surfaced.GobjectId);
|
||||
Assert.True(result.ChildHasChildren[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_UnknownParent_ThrowsNotFound()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
BrowseChildrenRequest request = new() { ParentGobjectId = 99999 };
|
||||
|
||||
RpcException ex = Assert.Throws<RpcException>(() =>
|
||||
GalaxyBrowseProjector.ProjectChildren(entry, request, null, 0, 100));
|
||||
Assert.Equal(StatusCode.NotFound, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectChildren_Paging_SlicesAndPreservesTotal()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
// LineA(2) has two direct children: Pump01, Valve01.
|
||||
BrowseChildrenRequest request = new() { ParentGobjectId = 2 };
|
||||
|
||||
GalaxyBrowseChildrenResult page1 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 0, pageSize: 1);
|
||||
GalaxyBrowseChildrenResult page2 = GalaxyBrowseProjector.ProjectChildren(entry, request, null, offset: 1, pageSize: 1);
|
||||
|
||||
Assert.Equal(2, page1.TotalChildCount);
|
||||
Assert.Single(page1.Children);
|
||||
Assert.Single(page2.Children);
|
||||
Assert.NotEqual(page1.Children[0].GobjectId, page2.Children[0].GobjectId);
|
||||
// Same filter+parent => same signature on both pages.
|
||||
Assert.Equal(page1.FilterSignature, page2.FilterSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveParentId_ByTagName_ResolvesToGobjectId()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
BrowseChildrenRequest request = new() { ParentTagName = "LineA" };
|
||||
|
||||
int id = GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||
|
||||
Assert.Equal(2, id);
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Server-005 regression: the initial <c>RefreshAsync</c> call in
|
||||
/// <see cref="GalaxyHierarchyRefreshService"/> must not let a transient,
|
||||
/// non-cancellation first-load failure (e.g. a <see cref="TimeoutException"/>
|
||||
/// or <see cref="System.ComponentModel.Win32Exception"/> from connection
|
||||
/// establishment) escape and fault the host <c>BackgroundService</c>.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyRefreshServiceTests
|
||||
{
|
||||
/// <summary>Verifies that the background service does not fault when the first refresh throws a non-cancellation exception.</summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenFirstRefreshThrowsNonCancellationException_DoesNotFaultBackgroundService()
|
||||
{
|
||||
ThrowingCache cache = new(new TimeoutException("connection establishment timed out"));
|
||||
GalaxyHierarchyRefreshService service = CreateService(cache);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
|
||||
await service.StartAsync(cts.Token);
|
||||
|
||||
// Wait until the first RefreshAsync has actually been attempted (and
|
||||
// thrown) before cancelling, so cancellation cannot race ahead of the
|
||||
// first-load path under test — this is what made the test flaky under
|
||||
// parallel load.
|
||||
await cache.FirstRefreshAttempted.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
await cts.CancelAsync();
|
||||
|
||||
// The background loop must have stopped cleanly: ExecuteTask reaches a
|
||||
// terminal state that is not Faulted (RanToCompletion or Canceled)
|
||||
// rather than faulting on the first refresh. WhenAny is used so a
|
||||
// Canceled task does not rethrow before the IsFaulted assertion.
|
||||
Task? executeTask = service.ExecuteTask;
|
||||
Assert.NotNull(executeTask);
|
||||
Task completed = await Task.WhenAny(executeTask, Task.Delay(TimeSpan.FromSeconds(10)));
|
||||
Assert.Same(executeTask, completed);
|
||||
Assert.False(executeTask.IsFaulted);
|
||||
Assert.Equal(1, cache.RefreshCallCount);
|
||||
|
||||
await service.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyRefreshService CreateService(IGalaxyHierarchyCache cache)
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
DashboardRefreshIntervalSeconds = 3600,
|
||||
};
|
||||
return new GalaxyHierarchyRefreshService(
|
||||
cache,
|
||||
Options.Create(options),
|
||||
NullLogger<GalaxyHierarchyRefreshService>.Instance);
|
||||
}
|
||||
|
||||
private sealed class ThrowingCache(Exception toThrow) : IGalaxyHierarchyCache
|
||||
{
|
||||
private readonly TaskCompletionSource firstRefreshAttempted =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the number of refresh calls.</summary>
|
||||
public int RefreshCallCount { get; private set; }
|
||||
|
||||
/// <summary>Gets a task that completes once refresh has been invoked at least once.</summary>
|
||||
public Task FirstRefreshAttempted => firstRefreshAttempted.Task;
|
||||
|
||||
/// <summary>Gets the current cache entry.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current => GalaxyHierarchyCacheEntry.Empty;
|
||||
|
||||
/// <summary>Refreshes the cache asynchronously and throws the configured exception.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
RefreshCallCount++;
|
||||
firstRefreshAttempted.TrySetResult();
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <summary>Waits for the first load and completes immediately.</summary>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip tests for the real <see cref="GalaxyHierarchySnapshotStore"/> over a temp
|
||||
/// file path: save then load, no-op when persistence is disabled, and clean disposal.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchySnapshotStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _path = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"galaxyrepo-snap-{Guid.NewGuid():N}.json");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_path))
|
||||
{
|
||||
File.Delete(_path);
|
||||
}
|
||||
}
|
||||
|
||||
private static GalaxyHierarchySnapshot SampleSnapshot() => new(
|
||||
LastDeployTime: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
SavedAt: new DateTimeOffset(2026, 1, 1, 12, 0, 0, TimeSpan.Zero),
|
||||
Hierarchy:
|
||||
[
|
||||
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Area1", IsArea = true },
|
||||
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump01", ParentGobjectId = 1 },
|
||||
],
|
||||
Attributes:
|
||||
[
|
||||
new GalaxyAttributeRow { GobjectId = 2, AttributeName = "PV", FullTagReference = "Pump01.PV", IsHistorized = true },
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public async Task SaveThenLoad_RoundTripsTheSnapshot()
|
||||
{
|
||||
using GalaxyHierarchySnapshotStore store = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||
|
||||
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||
GalaxyHierarchySnapshot? loaded = await store.TryLoadAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(2, loaded!.Hierarchy.Count);
|
||||
Assert.Single(loaded.Attributes);
|
||||
Assert.Equal("Pump01.PV", loaded.Attributes[0].FullTagReference);
|
||||
Assert.True(loaded.Attributes[0].IsHistorized);
|
||||
Assert.Equal(SampleSnapshot().LastDeployTime, loaded.LastDeployTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAndLoad_AreNoOps_WhenPersistenceDisabled()
|
||||
{
|
||||
using GalaxyHierarchySnapshotStore store = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = false, SnapshotCachePath = _path }));
|
||||
|
||||
await store.SaveAsync(SampleSnapshot(), CancellationToken.None);
|
||||
|
||||
Assert.False(File.Exists(_path));
|
||||
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryLoad_ReturnsNull_WhenNoFileExists()
|
||||
{
|
||||
using GalaxyHierarchySnapshotStore store = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||
|
||||
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryLoad_ReturnsNull_WhenFileIsNotValidJson()
|
||||
{
|
||||
await File.WriteAllTextAsync(_path, "{ this is not valid json");
|
||||
using GalaxyHierarchySnapshotStore store = new(
|
||||
Options.Create(new GalaxyRepositoryOptions { PersistSnapshot = true, SnapshotCachePath = _path }));
|
||||
|
||||
Assert.Null(await store.TryLoadAsync(CancellationToken.None));
|
||||
}
|
||||
}
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.GalaxyRepository.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.GalaxyRepository.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="GalaxyRepositoryGrpcService"/> scopes browse/discover
|
||||
/// results through the injected <see cref="IGalaxyBrowseScopeProvider"/>. The default
|
||||
/// (null-returning) provider must preserve full-hierarchy behavior, and a provider
|
||||
/// returning a glob that matches nothing must filter the result to empty.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryGrpcServiceScopeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A scope provider built with a <see langword="null"/> result behaves like the
|
||||
/// default <see cref="NullGalaxyBrowseScopeProvider"/>: DiscoverHierarchy returns
|
||||
/// the full hierarchy.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchy_DefaultScope_ReturnsFullHierarchy()
|
||||
{
|
||||
GalaxyRepositoryGrpcService service = CreateService(
|
||||
BuildSampleEntry(),
|
||||
new FakeBrowseScopeProvider(subtrees: null));
|
||||
|
||||
DiscoverHierarchyReply reply = await service.DiscoverHierarchy(
|
||||
new DiscoverHierarchyRequest { PageSize = 100 },
|
||||
new TestServerCallContext());
|
||||
|
||||
// The sample hierarchy has six objects; with no scoping all are returned.
|
||||
Assert.Equal(6, reply.TotalObjectCount);
|
||||
Assert.Equal(6, reply.Objects.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A scope provider returning a glob that matches no contained path filters the
|
||||
/// children to empty, mirroring mxaccessgw's browse-subtree constraint behavior.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task BrowseChildren_ScopedProvider_FiltersChildren()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
// Sanity: with the default (unscoped) provider, LineA(2) has two children.
|
||||
GalaxyRepositoryGrpcService unscopedService = CreateService(
|
||||
entry,
|
||||
new FakeBrowseScopeProvider(subtrees: null));
|
||||
BrowseChildrenReply unscoped = await unscopedService.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
Assert.Equal(2, unscoped.Children.Count);
|
||||
|
||||
// A glob matching nothing scopes the result to empty.
|
||||
GalaxyRepositoryGrpcService scopedService = CreateService(
|
||||
entry,
|
||||
new FakeBrowseScopeProvider(subtrees: ["NonExistent"]));
|
||||
BrowseChildrenReply scoped = await scopedService.BrowseChildren(
|
||||
new BrowseChildrenRequest { ParentGobjectId = 2 },
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Empty(scoped.Children);
|
||||
Assert.Equal(0, scoped.TotalChildCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the scope provider returns a non-empty glob, the deploy event's
|
||||
/// object/attribute counts are re-projected against the scoped subtree and override
|
||||
/// the raw counts the notifier published.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WatchDeployEvents_ScopedProvider_EmitsFilteredCounts()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = BuildSampleEntry();
|
||||
|
||||
// Sanity: the full hierarchy projects to six objects / four attributes.
|
||||
GalaxyHierarchyQueryResult full = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest());
|
||||
Assert.Equal(6, full.TotalObjectCount);
|
||||
Assert.Equal(4, full.Objects.Sum(obj => obj.Attributes.Count));
|
||||
|
||||
// The glob selects only LineA's two leaf objects (Pump01, Valve01), each with one
|
||||
// attribute. That scoped projection (2 objects / 2 attributes) is a non-empty subset
|
||||
// distinct from both the full count and the raw notifier values below.
|
||||
GalaxyHierarchyQueryResult scopedProjection = GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtreeGlobs: ["PlantArea/LineA/*"]);
|
||||
Assert.Equal(2, scopedProjection.TotalObjectCount);
|
||||
Assert.Equal(2, scopedProjection.Objects.Sum(obj => obj.Attributes.Count));
|
||||
|
||||
// Publish a deploy event whose RAW counts differ from both full and scoped, so an
|
||||
// assertion on the scoped values proves the override actually happened.
|
||||
RecordingDeployNotifier notifier = new();
|
||||
notifier.Publish(new GalaxyDeployEventInfo(
|
||||
Sequence: 42,
|
||||
ObservedAt: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
TimeOfLastDeploy: new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
|
||||
ObjectCount: 999,
|
||||
AttributeCount: 888));
|
||||
|
||||
GalaxyRepositoryGrpcService service = CreateService(
|
||||
entry,
|
||||
new FakeBrowseScopeProvider(subtrees: ["PlantArea/LineA/*"]),
|
||||
notifier);
|
||||
|
||||
// RecordingDeployNotifier yields the latest event then completes, so the stream
|
||||
// ends after the single event without needing cancellation.
|
||||
CapturingStreamWriter responseStream = new();
|
||||
await service.WatchDeployEvents(
|
||||
new WatchDeployEventsRequest(),
|
||||
responseStream,
|
||||
new TestServerCallContext());
|
||||
|
||||
DeployEvent emitted = Assert.Single(responseStream.Written);
|
||||
Assert.Equal(scopedProjection.TotalObjectCount, emitted.ObjectCount);
|
||||
Assert.Equal(2, emitted.AttributeCount);
|
||||
// The raw notifier values were overridden by the scoped re-projection.
|
||||
Assert.NotEqual(999, emitted.ObjectCount);
|
||||
Assert.NotEqual(888, emitted.AttributeCount);
|
||||
}
|
||||
|
||||
private static GalaxyRepositoryGrpcService CreateService(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
IGalaxyBrowseScopeProvider scope,
|
||||
IGalaxyDeployNotifier? notifier = null)
|
||||
{
|
||||
// No test here calls TestConnection, so a fake repository (no real SQL) is enough
|
||||
// and removes any latent localhost-connection risk.
|
||||
return new GalaxyRepositoryGrpcService(
|
||||
new FakeGalaxyRepository(
|
||||
Array.Empty<GalaxyHierarchyRow>(),
|
||||
Array.Empty<GalaxyAttributeRow>(),
|
||||
deployTime: null),
|
||||
new StubGalaxyHierarchyCache(entry),
|
||||
notifier ?? new RecordingDeployNotifier(),
|
||||
scope);
|
||||
}
|
||||
|
||||
// A small but representative galaxy, materialized through the real cache refresh path
|
||||
// so the projectors run against a real GalaxyHierarchyIndex:
|
||||
// PlantArea (area, id 1)
|
||||
// ├─ LineA (area, id 2)
|
||||
// │ ├─ Pump01 (id 10)
|
||||
// │ └─ Valve01 (id 11)
|
||||
// └─ Mixer01 (id 12)
|
||||
// StandaloneTank (id 20, root)
|
||||
private static GalaxyHierarchyCacheEntry BuildSampleEntry()
|
||||
{
|
||||
List<GalaxyHierarchyRow> hierarchy =
|
||||
[
|
||||
Hierarchy(1, "PlantArea", parent: 0, isArea: true, category: 100),
|
||||
Hierarchy(2, "LineA", parent: 1, isArea: true, category: 100),
|
||||
Hierarchy(10, "Pump01", parent: 2, category: 200, templates: ["$Pump"]),
|
||||
Hierarchy(11, "Valve01", parent: 2, category: 201, templates: ["$Valve"]),
|
||||
Hierarchy(12, "Mixer01", parent: 1, category: 202, templates: ["$Mixer"]),
|
||||
Hierarchy(20, "StandaloneTank", parent: 0, category: 203, templates: ["$Tank"]),
|
||||
];
|
||||
|
||||
List<GalaxyAttributeRow> attributes =
|
||||
[
|
||||
Attribute(10, "Pump01.PV"),
|
||||
Attribute(11, "Valve01.Cmd"),
|
||||
Attribute(12, "Mixer01.Fault"),
|
||||
Attribute(20, "StandaloneTank.Level"),
|
||||
];
|
||||
|
||||
FakeGalaxyRepository repository = new(
|
||||
hierarchy,
|
||||
attributes,
|
||||
deployTime: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
using GalaxyHierarchyCache cache = new(repository, new RecordingDeployNotifier());
|
||||
cache.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
Assert.True(entry.HasData);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyRow Hierarchy(
|
||||
int id,
|
||||
string tagName,
|
||||
int parent,
|
||||
bool isArea = false,
|
||||
int category = 0,
|
||||
IReadOnlyList<string>? templates = null) => new()
|
||||
{
|
||||
GobjectId = id,
|
||||
TagName = tagName,
|
||||
ContainedName = tagName,
|
||||
BrowseName = tagName,
|
||||
ParentGobjectId = parent,
|
||||
IsArea = isArea,
|
||||
CategoryId = category,
|
||||
TemplateChain = templates ?? Array.Empty<string>(),
|
||||
};
|
||||
|
||||
private static GalaxyAttributeRow Attribute(int gobjectId, string fullTagReference) => new()
|
||||
{
|
||||
GobjectId = gobjectId,
|
||||
AttributeName = fullTagReference.Split('.')[^1],
|
||||
FullTagReference = fullTagReference,
|
||||
};
|
||||
|
||||
/// <summary>An <see cref="IGalaxyBrowseScopeProvider"/> that returns a fixed glob list.</summary>
|
||||
private sealed class FakeBrowseScopeProvider(IReadOnlyList<string>? subtrees) : IGalaxyBrowseScopeProvider
|
||||
{
|
||||
public IReadOnlyList<string>? ResolveBrowseSubtrees(ServerCallContext context) => subtrees;
|
||||
}
|
||||
|
||||
/// <summary>Serves a fixed cache entry; never blocks on first load.</summary>
|
||||
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
|
||||
{
|
||||
public GalaxyHierarchyCacheEntry Current { get; } = current;
|
||||
|
||||
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Records every <see cref="DeployEvent"/> the service streams.</summary>
|
||||
private sealed class CapturingStreamWriter : IServerStreamWriter<DeployEvent>
|
||||
{
|
||||
public List<DeployEvent> Written { get; } = [];
|
||||
|
||||
public WriteOptions? WriteOptions { get; set; }
|
||||
|
||||
public Task WriteAsync(DeployEvent message)
|
||||
{
|
||||
Written.Add(message);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Minimal in-memory <see cref="ServerCallContext"/> for direct service unit tests.</summary>
|
||||
private sealed class TestServerCallContext : ServerCallContext
|
||||
{
|
||||
private readonly Metadata _requestHeaders = [];
|
||||
private readonly Metadata _responseTrailers = [];
|
||||
private readonly Dictionary<object, object> _userState = [];
|
||||
private Status _status;
|
||||
private WriteOptions? _writeOptions;
|
||||
|
||||
protected override string MethodCore => "/zb.galaxy.v1.GalaxyRepository/Test";
|
||||
|
||||
protected override string HostCore => "localhost";
|
||||
|
||||
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||
|
||||
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||
|
||||
protected override Metadata RequestHeadersCore => _requestHeaders;
|
||||
|
||||
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
||||
|
||||
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
||||
|
||||
protected override Status StatusCore
|
||||
{
|
||||
get => _status;
|
||||
set => _status = value;
|
||||
}
|
||||
|
||||
protected override WriteOptions? WriteOptionsCore
|
||||
{
|
||||
get => _writeOptions;
|
||||
set => _writeOptions = value;
|
||||
}
|
||||
|
||||
protected override AuthContext AuthContextCore { get; } = new(
|
||||
string.Empty,
|
||||
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||
|
||||
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||
|
||||
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
||||
|
||||
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Test project does not ship; no XML docs required (overrides Directory.Build.props). -->
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.GalaxyRepository\ZB.MOM.WW.GalaxyRepository.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -4,7 +4,7 @@ Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGate
|
||||
|
||||
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
|
||||
|
||||
**Built at 0.1.0. NOT yet adopted by the three apps.** Adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
|
||||
**Built at 0.3.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,7 @@ The library normalizes the three-tier health endpoint convention (`/health/ready
|
||||
| Package | Responsibilities | Key Dependencies |
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Health` | Core tier convention, `MapZbHealth` extension, canonical JSON writer (`ZbHealthWriter`), `IActiveNodeGate` seam, `GrpcDependencyHealthCheck` reachability probe, tier-tag constants (`ZbHealthTags`). No Akka or EF dependency. | `Microsoft.AspNetCore.App` (framework ref), `Grpc.Net.Client` |
|
||||
| `ZB.MOM.WW.Health.Akka` | `AkkaClusterHealthCheck` with a configurable `AkkaClusterStatusPolicy` (presets: `Default` / `OtOpcUaCompat`), `ActiveNodeHealthCheck` with an optional role filter, and `AkkaActiveNodeGate` that backs `IActiveNodeGate` from cluster member state. | `ZB.MOM.WW.Health`, `Akka.Cluster` |
|
||||
| `ZB.MOM.WW.Health.Akka` | `AkkaClusterHealthCheck` with a configurable `AkkaClusterStatusPolicy` (presets: `Default` / `OtOpcUaCompat`); `ClusterActiveNode` — the family-wide **oldest-`Up`-member** rule, optionally role-scoped; `ActiveNodeHealthCheck` over it (role-preference list, `activeRole`/`activeNode` in the result `data`); and `AkkaActiveNodeGate`, which backs `IActiveNodeGate` from the same rule. | `ZB.MOM.WW.Health`, `Akka.Cluster` |
|
||||
| `ZB.MOM.WW.Health.EntityFrameworkCore` | `DatabaseHealthCheck<TContext>` — resolves `IDbContextFactory<TContext>` when registered, else a scoped `TContext`; pool-safe. Default probe: `CanConnectAsync`. Optional `ProbeQuery` delegate for query-based validation. | `ZB.MOM.WW.Health`, `Microsoft.EntityFrameworkCore` |
|
||||
|
||||
---
|
||||
@@ -46,14 +46,19 @@ dotnet test ZB.MOM.WW.Health.slnx
|
||||
dotnet pack ZB.MOM.WW.Health.slnx -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
All three test assemblies run offline:
|
||||
All three test assemblies run offline. The Akka suite forms real in-process clusters on loopback: a
|
||||
single-node one for the cluster-data payload, and a **two-node one built so the oldest member is not
|
||||
the lowest-addressed one** — the only condition under which age- and leader-ordering disagree, and so
|
||||
the only fixture that can catch a leader-based active-node regression. It binds fixed ports
|
||||
19540/19541 (distinct from OtOpcUa's equivalent fixture) and carries a sanity check that the
|
||||
divergence actually occurred, so the assertions cannot pass for the wrong reason:
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Health.Tests` | 20 |
|
||||
| `ZB.MOM.WW.Health.Akka.Tests` | 32 |
|
||||
| `ZB.MOM.WW.Health.Tests` | 25 |
|
||||
| `ZB.MOM.WW.Health.Akka.Tests` | 45 |
|
||||
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
||||
| **Total** | **58** |
|
||||
| **Total** | **76** |
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
|
||||
|
||||
@@ -61,7 +66,7 @@ All three test assemblies run offline:
|
||||
|
||||
## Status
|
||||
|
||||
Built at **0.1.0** and published to the Gitea NuGet feed. **Not yet adopted** by the three apps — adoption is tracked in the component backlog:
|
||||
Built at **0.3.0** and published to the Gitea NuGet feed. **Adopted** by all four apps — adoption is tracked in the component backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/health/GAPS.md` — adoption order, effort, and risk
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.3.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<!-- Emit XML docs so the public API summaries ship inside the packed nupkgs (IntelliSense for
|
||||
consumers). CS1591 (missing doc on a public member) is suppressed so undocumented test /
|
||||
|
||||
@@ -30,7 +30,19 @@ MxAccessGateway consumes the core package only — it has no Akka cluster and no
|
||||
|
||||
## Versioning
|
||||
|
||||
All three packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps all three packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Health packages.
|
||||
All three packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.2.0**. A single version bump in `Directory.Build.props` bumps all three packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Health packages.
|
||||
|
||||
### 0.2.0 — per-entry `data` (additive, non-breaking)
|
||||
|
||||
- `ZbHealthWriter` emits an optional `"data": { … }` object per entry, sourced from the check's
|
||||
`HealthCheckResult.Data`, **only when non-empty** — a check that publishes no data produces a
|
||||
byte-identical body to 0.1.0, so every existing consumer is unaffected. Data keys are written
|
||||
verbatim (the camelCase policy applies to the envelope, not to dictionary keys).
|
||||
- `AkkaClusterHealthCheck` populates that dictionary with this node's cluster view: `leader`
|
||||
(omitted while unknown), `selfAddress`, `selfRoles`, `memberCount`, `unreachableCount`. The
|
||||
startup-safety paths (no `ActorSystem`, cluster not yet accessible) stay description-only.
|
||||
- Consumers gain the leader field on a package bump alone — no application code change, provided the
|
||||
app registers the shared `AkkaClusterHealthCheck`.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,14 +54,16 @@ dotnet build ZB.MOM.WW.Health.slnx
|
||||
dotnet test ZB.MOM.WW.Health.slnx
|
||||
```
|
||||
|
||||
All three test assemblies run with `dotnet test` and require no external dependencies (no running Akka cluster, no database):
|
||||
All three test assemblies run with `dotnet test` and require no external dependencies — no database and no
|
||||
external cluster (the cluster-data tests form a single-node in-process Akka cluster on a loopback port
|
||||
via `Akka.TestKit.Xunit2`):
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Health.Tests` | 20 |
|
||||
| `ZB.MOM.WW.Health.Akka.Tests` | 32 |
|
||||
| `ZB.MOM.WW.Health.Tests` | 25 |
|
||||
| `ZB.MOM.WW.Health.Akka.Tests` | 39 |
|
||||
| `ZB.MOM.WW.Health.EntityFrameworkCore.Tests` | 6 |
|
||||
| **Total** | **58** |
|
||||
| **Total** | **70** |
|
||||
|
||||
---
|
||||
|
||||
@@ -62,9 +76,9 @@ dotnet pack ZB.MOM.WW.Health.slnx -c Release -o ./artifacts
|
||||
Produces three `.nupkg` files in `artifacts/`:
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Health.0.1.0.nupkg
|
||||
ZB.MOM.WW.Health.Akka.0.1.0.nupkg
|
||||
ZB.MOM.WW.Health.EntityFrameworkCore.0.1.0.nupkg
|
||||
ZB.MOM.WW.Health.0.2.0.nupkg
|
||||
ZB.MOM.WW.Health.Akka.0.2.0.nupkg
|
||||
ZB.MOM.WW.Health.EntityFrameworkCore.0.2.0.nupkg
|
||||
```
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly as above.
|
||||
@@ -73,7 +87,7 @@ ZB.MOM.WW.Health.EntityFrameworkCore.0.1.0.nupkg
|
||||
|
||||
## Status
|
||||
|
||||
**Built at 0.1.0. NOT yet adopted by the three apps.** Adoption is tracked in the component backlog:
|
||||
**Built at 0.2.0 and published to the Gitea NuGet feed. Adopted by all four apps** (OtOpcUa, MxAccessGateway, ScadaBridge, HistorianGateway). Adoption is tracked in the component backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/health/GAPS.md`
|
||||
|
||||
|
||||
@@ -9,89 +9,80 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
namespace ZB.MOM.WW.Health.Akka;
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision function for the active / leader probe, factored out of
|
||||
/// <see cref="ActiveNodeHealthCheck"/> so the role-less and role-filtered matrices are exhaustively
|
||||
/// table-testable without forming a real cluster.
|
||||
/// </summary>
|
||||
internal static class ActiveNodeDecision
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps the resolved cluster facts to a <see cref="HealthStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="selfUp">Whether the local node's member status is <c>Up</c>.</param>
|
||||
/// <param name="isLeader">
|
||||
/// Whether the local node is the leader: the cluster leader in role-less mode, or the
|
||||
/// role-singleton leader in role-filtered mode.
|
||||
/// </param>
|
||||
/// <param name="hasRole">
|
||||
/// Whether the local node carries <paramref name="requiredRole"/>. Ignored when
|
||||
/// <paramref name="requiredRole"/> is <c>null</c>.
|
||||
/// </param>
|
||||
/// <param name="requiredRole">
|
||||
/// The role to scope the check to, or <c>null</c> for the role-less (whole-cluster-leader) mode.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// Role-less: Healthy iff the node is Up and the cluster leader, otherwise Unhealthy.
|
||||
/// Role-filtered: Healthy when the node lacks the role (probe irrelevant) or carries the role and
|
||||
/// is the role-singleton leader; Degraded when it carries the role but is not the leader.
|
||||
/// </returns>
|
||||
public static HealthStatus Evaluate(bool selfUp, bool isLeader, bool hasRole, string? requiredRole)
|
||||
{
|
||||
if (requiredRole is null)
|
||||
return selfUp && isLeader ? HealthStatus.Healthy : HealthStatus.Unhealthy;
|
||||
|
||||
if (!hasRole)
|
||||
return HealthStatus.Healthy;
|
||||
|
||||
return isLeader ? HealthStatus.Healthy : HealthStatus.Degraded;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health check that reports whether this node is the designated active / leader node.
|
||||
/// An optional role scopes the check to nodes carrying that role. Register to the
|
||||
/// <see cref="ZbHealthTags.Active"/> tag.
|
||||
/// Active-tier probe: Healthy (200) on the one node in charge, Unhealthy (503) on its standby.
|
||||
/// Register to the <see cref="ZbHealthTags.Active"/> tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider. If it is not yet
|
||||
/// available — e.g. during startup before Akka is initialised — the check returns
|
||||
/// <see cref="HealthStatus.Degraded"/> rather than throwing, so it is startup-safe.
|
||||
/// <para>
|
||||
/// "In charge" means the <b>oldest Up member</b> — see <see cref="ClusterActiveNode"/> for
|
||||
/// why leadership is the wrong answer and what breaks when it is used. Scope the competition
|
||||
/// to a role with <see cref="ActiveNodeHealthCheckOptions.RolePreference"/>; leave it empty
|
||||
/// for a cluster whose nodes all compete for the same single active slot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Behaviour change in 0.3.0.</b> Through 0.2.1 this check selected by
|
||||
/// <c>ClusterState.Leader</c> / <c>RoleLeader</c>, and its role-filtered mode reported
|
||||
/// Healthy for any node <i>lacking</i> the role. Both were wrong, both were found on live
|
||||
/// rigs, and both Akka consumers had already replaced it with a private copy rather than use
|
||||
/// it. It now selects by age and reports <see cref="HealthStatus.Unhealthy"/> for a node that
|
||||
/// owns no active work. A node whose active tier previously answered 200 unconditionally will
|
||||
/// now answer 503 unless it really is the active node — which is the point.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Startup-safe: before the <see cref="ActorSystem"/> or the cluster is available the result
|
||||
/// is <see cref="HealthStatus.Degraded"/> (a 200 under the tier mapping), so a node that is
|
||||
/// merely still booting is not reported as a failed standby and pulled from rotation on the
|
||||
/// way up.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ActiveNodeHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string? _role;
|
||||
private readonly ActiveNodeHealthCheckOptions _options;
|
||||
|
||||
/// <summary>
|
||||
/// Role-less constructor: Healthy when the node is <c>Up</c> and the cluster leader
|
||||
/// (ScadaBridge ActiveNode pattern); Unhealthy otherwise. Degraded when the ActorSystem /
|
||||
/// cluster is not yet ready.
|
||||
/// Unscoped: the active node is the oldest Up member of the whole cluster.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">
|
||||
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
|
||||
/// check is startup-safe: if no <see cref="ActorSystem"/> is registered yet the result is Degraded.
|
||||
/// check is startup-safe.
|
||||
/// </param>
|
||||
public ActiveNodeHealthCheck(IServiceProvider serviceProvider)
|
||||
: this(serviceProvider, new ActiveNodeHealthCheckOptions())
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_role = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Role-filtered constructor: Healthy when the node lacks <paramref name="role"/> or carries it
|
||||
/// and is the role-singleton leader; Degraded when it carries the role but is not the leader
|
||||
/// (OtOpcUa AdminRoleLeader pattern). Degraded when the ActorSystem / cluster is not yet ready.
|
||||
/// Role-scoped: the active node is the oldest Up member carrying <paramref name="role"/>.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">
|
||||
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
|
||||
/// check is startup-safe: if no <see cref="ActorSystem"/> is registered yet the result is Degraded.
|
||||
/// check is startup-safe.
|
||||
/// </param>
|
||||
/// <param name="role">The Akka cluster role to scope the check to.</param>
|
||||
/// <param name="role">The cluster role to scope the competition to.</param>
|
||||
public ActiveNodeHealthCheck(IServiceProvider serviceProvider, string role)
|
||||
: this(serviceProvider, NewOptions(role))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configured with an explicit <see cref="ActiveNodeHealthCheckOptions"/>.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">
|
||||
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily so the
|
||||
/// check is startup-safe.
|
||||
/// </param>
|
||||
/// <param name="options">The role-scoping options.</param>
|
||||
public ActiveNodeHealthCheck(IServiceProvider serviceProvider, ActiveNodeHealthCheckOptions options)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
private static ActiveNodeHealthCheckOptions NewOptions(string role)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(role);
|
||||
_role = role;
|
||||
return new ActiveNodeHealthCheckOptions { RolePreference = new[] { role } };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -103,10 +94,8 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
MemberStatus selfStatus;
|
||||
bool selfUp;
|
||||
bool hasRole;
|
||||
bool isLeader;
|
||||
Member self;
|
||||
IEnumerable<Member> members;
|
||||
try
|
||||
{
|
||||
// Reading cluster membership can throw while the ActorSystem exists but the cluster has
|
||||
@@ -114,54 +103,49 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck
|
||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded rather
|
||||
// than letting the exception escape (which the host would record as Unhealthy).
|
||||
var cluster = Cluster.Get(system);
|
||||
var self = cluster.SelfMember;
|
||||
selfStatus = self.Status;
|
||||
selfUp = selfStatus == MemberStatus.Up;
|
||||
|
||||
if (_role is null)
|
||||
{
|
||||
hasRole = false;
|
||||
var leader = cluster.State.Leader;
|
||||
isLeader = leader is not null && leader == self.Address;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasRole = self.HasRole(_role);
|
||||
var roleLeader = cluster.State.RoleLeader(_role);
|
||||
isLeader = roleLeader is not null && roleLeader == self.Address;
|
||||
}
|
||||
self = cluster.SelfMember;
|
||||
members = cluster.State.Members;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role);
|
||||
var description = DescribeResult(health, selfStatus, selfUp, isLeader);
|
||||
var result = health switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description),
|
||||
_ => HealthCheckResult.Unhealthy(description),
|
||||
};
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
var scoped = _options.RolePreference.Count > 0;
|
||||
var role = ClusterActiveNode.ResolveActiveRole(self.Roles, _options.RolePreference);
|
||||
|
||||
private string DescribeResult(HealthStatus health, MemberStatus status, bool selfUp, bool isLeader)
|
||||
{
|
||||
if (_role is null)
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
if (health == HealthStatus.Healthy)
|
||||
return "Active node (cluster leader).";
|
||||
return selfUp && !isLeader
|
||||
? "Standby: node is Up but not the cluster leader."
|
||||
: $"Standby: node is not Up (status: {status}).";
|
||||
["selfAddress"] = self.Address.ToString(),
|
||||
};
|
||||
if (role is not null) data["activeRole"] = role;
|
||||
|
||||
if (scoped && role is null)
|
||||
{
|
||||
// The node carries none of the preferred roles, so it owns no active work and cannot be
|
||||
// the active node for any of them. Reported per NoActiveRoleStatus — Unhealthy by
|
||||
// default, because the active tier's whole job is to keep such a node out of the pool.
|
||||
var message = $"Node carries none of the active roles: {string.Join(", ", _options.RolePreference)}.";
|
||||
return Task.FromResult(new HealthCheckResult(_options.NoActiveRoleStatus, message, exception: null, data));
|
||||
}
|
||||
|
||||
return health switch
|
||||
var active = ClusterActiveNode.OldestUpMember(members, role);
|
||||
if (active is not null) data["activeNode"] = active.Address.ToString();
|
||||
|
||||
var scopeLabel = role is null ? "the cluster" : $"role '{role}'";
|
||||
|
||||
if (active is null)
|
||||
{
|
||||
HealthStatus.Healthy => $"Active for role '{_role}' (or not a role member).",
|
||||
_ => $"Role '{_role}' member but not leader.",
|
||||
};
|
||||
// Nobody is Up in the scope — a cold start, or every holder is still Joining or already
|
||||
// Leaving. Nobody is active, so nobody may claim to be.
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||
$"No Up member currently holds the active slot for {scopeLabel}.", exception: null, data));
|
||||
}
|
||||
|
||||
// Compared by UniqueAddress: a node that restarted on the same host and port is a different
|
||||
// member, and during the overlap the cluster briefly holds both incarnations.
|
||||
return Task.FromResult(active.UniqueAddress.Equals(self.UniqueAddress)
|
||||
? HealthCheckResult.Healthy($"Active node for {scopeLabel} (oldest Up member).", data)
|
||||
: HealthCheckResult.Unhealthy($"Standby: active node for {scopeLabel} is {active.Address}.", exception: null, data));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka;
|
||||
|
||||
/// <summary>
|
||||
/// Configures how <see cref="ActiveNodeHealthCheck"/> decides which role's active node this node is
|
||||
/// competing to be.
|
||||
/// </summary>
|
||||
public sealed class ActiveNodeHealthCheckOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Candidate roles in descending precedence. The check answers for the first one the local node
|
||||
/// carries. Empty (the default) means unscoped: the active node is the oldest Up member of the
|
||||
/// whole cluster.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A fused node should list the role its singletons are pinned to first — e.g.
|
||||
/// <c>["admin", "driver"]</c> for a node that hosts the admin singletons when fused and competes
|
||||
/// as a plain driver when it is a driver-only node.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<string> RolePreference { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// The status to report when <see cref="RolePreference"/> is non-empty and the node carries none
|
||||
/// of those roles — it owns no active work at all. Defaults to
|
||||
/// <see cref="HealthStatus.Unhealthy"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unhealthy is the safe default and is deliberately <b>not</b> the "not applicable ⇒ Healthy"
|
||||
/// convention used by readiness probes. The active tier exists so a load balancer can pick
|
||||
/// exactly one node; a node that owns no active work must not be in that pool. Answering Healthy
|
||||
/// there is what made the previous role-filtered check report 200 on every node and silently
|
||||
/// broke leader-pinning (lmxopcua#494). Override only when a node genuinely should stay in the
|
||||
/// active pool without holding any of the preferred roles.
|
||||
/// </remarks>
|
||||
public HealthStatus NoActiveRoleStatus { get; init; } = HealthStatus.Unhealthy;
|
||||
}
|
||||
@@ -6,27 +6,47 @@ namespace ZB.MOM.WW.Health.Akka;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IActiveNodeGate"/> implementation that computes <see cref="IsActiveNode"/> directly
|
||||
/// from the Akka cluster state (self member <c>Up</c> and the local node is the cluster leader).
|
||||
/// Register as a singleton.
|
||||
/// from the Akka cluster state — the local node is the <b>oldest Up member</b>, optionally within a
|
||||
/// role scope. Register as a singleton.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider; if it is not yet
|
||||
/// available — e.g. during startup before Akka is initialised — <see cref="IsActiveNode"/> returns
|
||||
/// <c>false</c> (the safe default during startup). This gate reads the cluster state directly and
|
||||
/// does not resolve <see cref="ActiveNodeHealthCheck"/> from DI.
|
||||
/// <para>
|
||||
/// Shares its rule with <see cref="ActiveNodeHealthCheck"/> via
|
||||
/// <see cref="ClusterActiveNode"/>, so an endpoint gated by this type and the
|
||||
/// <c>/health/active</c> probe an orchestrator routes by can never disagree about which node
|
||||
/// is in charge.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Behaviour change in 0.3.0:</b> through 0.2.1 this gate selected by
|
||||
/// <c>ClusterState.Leader</c>, which is address-ordered and diverges from singleton placement
|
||||
/// after any restart. See <see cref="ClusterActiveNode"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider; if it is not
|
||||
/// yet available — e.g. during startup before Akka is initialised —
|
||||
/// <see cref="IsActiveNode"/> returns <c>false</c> (the safe default during startup, matching
|
||||
/// the standby case). This gate reads the cluster state directly and does not resolve
|
||||
/// <see cref="ActiveNodeHealthCheck"/> from DI.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AkkaActiveNodeGate : IActiveNodeGate
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly string? _role;
|
||||
|
||||
/// <summary>Initializes a new <see cref="AkkaActiveNodeGate"/>.</summary>
|
||||
/// <param name="serviceProvider">
|
||||
/// The application service provider. The <see cref="ActorSystem"/> is resolved lazily; if it is
|
||||
/// not yet available <see cref="IsActiveNode"/> returns <c>false</c>.
|
||||
/// </param>
|
||||
public AkkaActiveNodeGate(IServiceProvider serviceProvider)
|
||||
/// <param name="role">
|
||||
/// The role to scope the competition to, or <c>null</c> (the default) to consider every Up
|
||||
/// member.
|
||||
/// </param>
|
||||
public AkkaActiveNodeGate(IServiceProvider serviceProvider, string? role = null)
|
||||
{
|
||||
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
|
||||
_role = role;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -38,13 +58,16 @@ public sealed class AkkaActiveNodeGate : IActiveNodeGate
|
||||
if (system is null)
|
||||
return false;
|
||||
|
||||
var cluster = Cluster.Get(system);
|
||||
var self = cluster.SelfMember;
|
||||
if (self.Status != MemberStatus.Up)
|
||||
try
|
||||
{
|
||||
return ClusterActiveNode.SelfIsActive(Cluster.Get(system), _role);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ActorSystem exists but the cluster is not initialised yet. Same startup-safety
|
||||
// rule as the health check, expressed as the gate's safe default: not active.
|
||||
return false;
|
||||
|
||||
var leader = cluster.State.Leader;
|
||||
return leader is not null && leader == self.Address;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
MemberStatus status;
|
||||
IReadOnlyDictionary<string, object> data;
|
||||
try
|
||||
{
|
||||
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
||||
@@ -51,7 +52,9 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
|
||||
// escaping exception (which the host would record as Unhealthy and pull the node from
|
||||
// rotation).
|
||||
status = Cluster.Get(system).SelfMember.Status;
|
||||
var cluster = Cluster.Get(system);
|
||||
status = cluster.SelfMember.Status;
|
||||
data = BuildClusterData(cluster);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -62,10 +65,47 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
var description = $"Akka cluster member status: {status}";
|
||||
var result = health switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description),
|
||||
_ => HealthCheckResult.Unhealthy(description),
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description, data),
|
||||
HealthStatus.Degraded => HealthCheckResult.Degraded(description, data: data),
|
||||
_ => HealthCheckResult.Unhealthy(description, data: data),
|
||||
};
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects this node's view of the cluster into the check's <c>data</c> dictionary.
|
||||
/// </summary>
|
||||
/// <param name="cluster">The cluster extension for the local <see cref="ActorSystem"/>.</param>
|
||||
/// <returns>
|
||||
/// JSON-friendly values only (string / int / string[]), so the canonical health writer can emit
|
||||
/// them verbatim: <c>leader</c> (omitted while the leader is unknown, e.g. pre-join or during
|
||||
/// convergence), <c>selfAddress</c>, <c>selfRoles</c> (sorted for a stable payload),
|
||||
/// <c>memberCount</c>, <c>unreachableCount</c>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This is deliberately each node's <em>own</em> view rather than an authoritative fleet answer —
|
||||
/// a consumer that reads it from several members of the same cluster can compare them, and a
|
||||
/// disagreement is a split-brain tell.
|
||||
/// </remarks>
|
||||
internal static IReadOnlyDictionary<string, object> BuildClusterData(Cluster cluster)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
|
||||
var state = cluster.State;
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["selfAddress"] = cluster.SelfAddress.ToString(),
|
||||
["selfRoles"] = cluster.SelfMember.Roles.OrderBy(static r => r, StringComparer.Ordinal).ToArray(),
|
||||
["memberCount"] = state.Members.Count,
|
||||
["unreachableCount"] = state.Unreachable.Count,
|
||||
};
|
||||
|
||||
// Omitted rather than emitted as null: a missing key reads as "this node does not know yet",
|
||||
// which is exactly the pre-join / mid-convergence state.
|
||||
var leader = state.Leader;
|
||||
if (leader is not null)
|
||||
data["leader"] = leader.ToString();
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using Akka.Cluster;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka;
|
||||
|
||||
/// <summary>
|
||||
/// THE family-wide definition of "the active node": the <b>oldest Up member</b> of the cluster,
|
||||
/// optionally scoped to a role — the member <c>ClusterSingletonManager</c> places singletons on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Cluster <b>leadership</b> (<c>ClusterState.Leader</c> / <c>RoleLeader</c>) is the wrong
|
||||
/// answer and must never be used for a product-level active/standby decision. Leadership is
|
||||
/// <i>address</i>-ordered (host, then port) and has no relationship to time; singleton
|
||||
/// placement is <i>age</i>-ordered. On a freshly-formed cluster the two agree, which is why
|
||||
/// the discrepancy hides in unit tests and in a rig that has never been restarted. They
|
||||
/// diverge permanently after any restart: the restarted node rejoins as the youngest but
|
||||
/// keeps its address, so if it holds the lower address it becomes leader while the
|
||||
/// singletons — and all the work they own — stay on the other node.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both Akka consumers in the family independently hit this and each wrote its own
|
||||
/// replacement rather than use the shared leader-based check
|
||||
/// (ScadaBridge <c>OldestNodeActiveHealthCheck</c>, OtOpcUa <c>ClusterPrimaryHealthCheck</c>).
|
||||
/// This type is those two implementations promoted to one, so the health tier, the inbound
|
||||
/// API gate, the store-and-forward delivery gate and OtOpcUa's OPC UA <c>ServiceLevel</c>
|
||||
/// cannot drift apart about which node is in charge.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only <see cref="MemberStatus.Up"/> members are eligible, matching singleton placement: a
|
||||
/// <c>Leaving</c> node is handing its singletons over and must not be named active.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ClusterActiveNode
|
||||
{
|
||||
/// <summary>
|
||||
/// The oldest <see cref="MemberStatus.Up"/> member, optionally scoped to <paramref name="role"/>.
|
||||
/// </summary>
|
||||
/// <param name="members">The cluster members, in any order.</param>
|
||||
/// <param name="role">
|
||||
/// The role to scope the selection to, or <c>null</c> to consider every Up member.
|
||||
/// </param>
|
||||
/// <returns>The oldest eligible member, or <c>null</c> when none is Up.</returns>
|
||||
public static Member? OldestUpMember(IEnumerable<Member> members, string? role = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(members);
|
||||
|
||||
Member? oldest = null;
|
||||
foreach (var member in members)
|
||||
{
|
||||
if (member.Status != MemberStatus.Up) continue;
|
||||
if (role is not null && !member.HasRole(role)) continue;
|
||||
if (oldest is null || member.IsOlderThan(oldest)) oldest = member;
|
||||
}
|
||||
|
||||
return oldest;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The oldest <see cref="MemberStatus.Up"/> member of <paramref name="cluster"/>, optionally
|
||||
/// scoped to <paramref name="role"/>.
|
||||
/// </summary>
|
||||
/// <param name="cluster">The Akka cluster to evaluate.</param>
|
||||
/// <param name="role">
|
||||
/// The role to scope the selection to, or <c>null</c> to consider every Up member.
|
||||
/// </param>
|
||||
/// <returns>The oldest eligible member, or <c>null</c> when none is Up.</returns>
|
||||
public static Member? OldestUpMember(Cluster cluster, string? role = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
return OldestUpMember(cluster.State.Members, role);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the local node is the active node — Up, and the oldest Up member in the role scope.
|
||||
/// </summary>
|
||||
/// <param name="cluster">The Akka cluster to evaluate.</param>
|
||||
/// <param name="role">
|
||||
/// The role to scope the selection to, or <c>null</c> to consider every Up member.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <c>true</c> when the local member is Up, carries <paramref name="role"/> when one is given,
|
||||
/// and no other eligible member is older.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Identity is compared by <see cref="Member.UniqueAddress"/>, not <c>Address</c>: a node that
|
||||
/// restarted on the same host and port is a different member, and during the overlap the cluster
|
||||
/// briefly holds both incarnations.
|
||||
/// </remarks>
|
||||
public static bool SelfIsActive(Cluster cluster, string? role = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
|
||||
var self = cluster.SelfMember;
|
||||
if (self.Status != MemberStatus.Up) return false;
|
||||
if (role is not null && !self.HasRole(role)) return false;
|
||||
|
||||
var oldest = OldestUpMember(cluster.State.Members, role);
|
||||
return oldest is not null && oldest.UniqueAddress.Equals(self.UniqueAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The first role in <paramref name="rolePreference"/> that <paramref name="selfRoles"/> carries
|
||||
/// — the role whose oldest Up member owns this node's active work.
|
||||
/// </summary>
|
||||
/// <param name="selfRoles">The local member's cluster roles.</param>
|
||||
/// <param name="rolePreference">
|
||||
/// Candidate roles in descending precedence, e.g. <c>["admin", "driver"]</c> for a node that
|
||||
/// answers for <c>admin</c> when it is fused and for <c>driver</c> when it is a driver-only node.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The first carried role, or <c>null</c> when the node carries none of them.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Precedence matters on a fused node: it must answer for the role its singletons — and the
|
||||
/// traffic being routed to it — are pinned to. Answering for a lower-precedence role could name a
|
||||
/// different node the moment a single-role member joins the mesh.
|
||||
/// </remarks>
|
||||
public static string? ResolveActiveRole(IEnumerable<string> selfRoles, IReadOnlyList<string> rolePreference)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(selfRoles);
|
||||
ArgumentNullException.ThrowIfNull(rolePreference);
|
||||
|
||||
if (rolePreference.Count == 0) return null;
|
||||
|
||||
var roles = selfRoles as IReadOnlyCollection<string> ?? selfRoles.ToList();
|
||||
foreach (var candidate in rolePreference)
|
||||
{
|
||||
if (roles.Contains(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
@@ -16,15 +17,18 @@ namespace ZB.MOM.WW.Health;
|
||||
/// "status": "Healthy|Degraded|Unhealthy",
|
||||
/// "totalDurationMs": 12.34,
|
||||
/// "entries": {
|
||||
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23 }
|
||||
/// "<name>": { "status": "...", "description": "...", "durationMs": 1.23, "data": { ... } }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
|
||||
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
|
||||
/// shape. The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded
|
||||
/// → 200, Unhealthy → 503); this writer only renders the body and sets
|
||||
/// <c>Content-Type: application/json</c>.
|
||||
/// shape. The <c>data</c> key is the opposite: it is emitted only when the check supplied a non-empty
|
||||
/// <see cref="HealthCheckResult.Data"/> dictionary, so payloads from checks that publish no data stay
|
||||
/// byte-identical to the pre-0.2.0 shape. Its keys are written verbatim (the camelCase naming policy
|
||||
/// applies to the envelope properties, not to dictionary keys). The HTTP status code is left to the
|
||||
/// ASP.NET Core health-checks middleware (Healthy/Degraded → 200, Unhealthy → 503); this writer only
|
||||
/// renders the body and sets <c>Content-Type: application/json</c>.
|
||||
/// </remarks>
|
||||
public static class ZbHealthWriter
|
||||
{
|
||||
@@ -60,6 +64,10 @@ public static class ZbHealthWriter
|
||||
Status = e.Value.Status.ToString(),
|
||||
Description = e.Value.Description,
|
||||
DurationMs = e.Value.Duration.TotalMilliseconds,
|
||||
// HealthReportEntry.Data is never null (empty when the check set none); mapping
|
||||
// empty → null is what drops the key entirely, keeping data-less payloads
|
||||
// byte-identical to the pre-0.2.0 shape.
|
||||
Data = e.Value.Data.Count > 0 ? e.Value.Data : null,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -78,5 +86,11 @@ public static class ZbHealthWriter
|
||||
public string Status { get; init; } = string.Empty;
|
||||
public string? Description { get; init; }
|
||||
public double DurationMs { get; init; }
|
||||
|
||||
// Per-property ignore, deliberately NOT a global DefaultIgnoreCondition: the writer must keep
|
||||
// emitting "description": null (see SerializerOptions above) while omitting "data" entirely
|
||||
// when a check publishes none.
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyDictionary<string, object>? Data { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Table-driven tests for the pure <see cref="ActiveNodeDecision.Evaluate"/> helper covering both
|
||||
/// the role-less (ScadaBridge ActiveNode) and role-filtered (OtOpcUa AdminRoleLeader) matrices,
|
||||
/// plus the startup-safety null-guards on <see cref="ActiveNodeHealthCheck"/> and
|
||||
/// <see cref="AkkaActiveNodeGate"/> when no <see cref="ActorSystem"/> is registered.
|
||||
/// </summary>
|
||||
public sealed class ActiveNodeDecisionTests
|
||||
{
|
||||
// Role-less: requiredRole == null. hasRole is irrelevant. Healthy iff (selfUp && isLeader), else Unhealthy.
|
||||
public static IEnumerable<object?[]> RoleLessCases() => new[]
|
||||
{
|
||||
new object?[] { true, true, false, (string?)null, HealthStatus.Healthy },
|
||||
new object?[] { true, false, false, (string?)null, HealthStatus.Unhealthy },
|
||||
new object?[] { false, true, false, (string?)null, HealthStatus.Unhealthy },
|
||||
new object?[] { false, false, false, (string?)null, HealthStatus.Unhealthy },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RoleLessCases))]
|
||||
public void Evaluate_RoleLess(bool selfUp, bool isLeader, bool hasRole, string? requiredRole, HealthStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, requiredRole));
|
||||
}
|
||||
|
||||
// Role-filtered: requiredRole != null.
|
||||
// lacks role -> Healthy (probe irrelevant for this node)
|
||||
// has role & is leader -> Healthy (selfUp is ignored — role-filtered mode only cares about leadership)
|
||||
// has role & not leader -> Degraded
|
||||
public static IEnumerable<object[]> RoleFilteredCases() => new[]
|
||||
{
|
||||
// node lacks the role -> Healthy regardless of selfUp / isLeader
|
||||
new object[] { true, true, false, "admin", HealthStatus.Healthy },
|
||||
new object[] { true, false, false, "admin", HealthStatus.Healthy },
|
||||
new object[] { false, false, false, "admin", HealthStatus.Healthy },
|
||||
// node carries the role and is leader -> Healthy (selfUp=true)
|
||||
new object[] { true, true, true, "admin", HealthStatus.Healthy },
|
||||
// node carries the role and is leader -> Healthy (selfUp=false: role-filtered mode ignores selfUp)
|
||||
new object[] { false, true, true, "admin", HealthStatus.Healthy },
|
||||
// node carries the role but is not leader -> Degraded
|
||||
new object[] { true, false, true, "admin", HealthStatus.Degraded },
|
||||
new object[] { false, false, true, "admin", HealthStatus.Degraded },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(RoleFilteredCases))]
|
||||
public void Evaluate_RoleFiltered(bool selfUp, bool isLeader, bool hasRole, string? requiredRole, HealthStatus expected)
|
||||
{
|
||||
Assert.Equal(expected, ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, requiredRole));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleLess_NoActorSystem_ReturnsDegraded()
|
||||
{
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleFiltered_NoActorSystem_ReturnsDegraded()
|
||||
{
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider, "admin");
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_NoActorSystem_IsActiveNodeFalse()
|
||||
{
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
var gate = new AkkaActiveNodeGate(provider);
|
||||
|
||||
Assert.False(gate.IsActiveNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleLess_ClusterInaccessible_ReturnsDegraded()
|
||||
{
|
||||
// ActorSystem present but Akka.Cluster not configured → Cluster.Get throws. The check must
|
||||
// return Degraded (startup-safety rule), not let the exception escape (→ Unhealthy).
|
||||
using var system = ActorSystem.Create("plain-no-cluster-roleless");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddSingleton(system)
|
||||
.BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleFiltered_ClusterInaccessible_ReturnsDegraded()
|
||||
{
|
||||
using var system = ActorSystem.Create("plain-no-cluster-rolefiltered");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddSingleton(system)
|
||||
.BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider, "admin");
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The states a node passes through on the way up, where the active tier must not report a booting
|
||||
/// node as a failed standby — now that the tier is a real 503, an Unhealthy here would pull the node
|
||||
/// out of an orchestrator's pool while it starts.
|
||||
/// </summary>
|
||||
public sealed class ActiveNodeStartupSafetyTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task NoActorSystem_IsDegraded()
|
||||
{
|
||||
var check = new ActiveNodeHealthCheck(new ServiceCollection().BuildServiceProvider());
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
// Description-only: an empty data dictionary is what makes the writer omit "data" entirely.
|
||||
Assert.Empty(result.Data);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoActorSystem_RoleScoped_IsDegraded()
|
||||
{
|
||||
var check = new ActiveNodeHealthCheck(new ServiceCollection().BuildServiceProvider(), "admin");
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, (await check.CheckHealthAsync(NewContext(check))).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_NoActorSystem_IsNotActive()
|
||||
{
|
||||
Assert.False(new AkkaActiveNodeGate(new ServiceCollection().BuildServiceProvider()).IsActiveNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClusterNotConfigured_IsDegraded()
|
||||
{
|
||||
// ActorSystem present but Akka.Cluster not configured → Cluster.Get throws. The check must
|
||||
// return Degraded (the spec's startup-safety rule), not let the exception escape (→ Unhealthy).
|
||||
using var system = ActorSystem.Create("active-node-no-cluster");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection().AddSingleton(system).BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider);
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, (await check.CheckHealthAsync(NewContext(check))).Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gate_ClusterNotConfigured_IsNotActive()
|
||||
{
|
||||
using var system = ActorSystem.Create("active-node-gate-no-cluster");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection().AddSingleton(system).BuildServiceProvider();
|
||||
|
||||
Assert.False(new AkkaActiveNodeGate(provider).IsActiveNode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClusteredButNeverJoined_IsUnhealthy_NobodyIsActive()
|
||||
{
|
||||
// A cold start: the cluster provider is up but no member has reached Up. Nobody is active, so
|
||||
// nobody may claim to be — this is the one case that must NOT be Degraded, because a cluster
|
||||
// that never forms would otherwise sit at 200 forever.
|
||||
var config = ConfigurationFactory.ParseString("""
|
||||
akka {
|
||||
loglevel = "WARNING"
|
||||
actor.provider = cluster
|
||||
remote.dot-netty.tcp { hostname = "127.0.0.1", port = 0 }
|
||||
cluster.seed-nodes = []
|
||||
}
|
||||
""");
|
||||
var system = ActorSystem.Create("active-node-never-joined", config);
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection().AddSingleton(system).BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("No Up member", result.Description);
|
||||
Assert.False(new AkkaActiveNodeGate(provider).IsActiveNode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="ClusterActiveNode.ResolveActiveRole"/> — which role a node competes for. Pure,
|
||||
/// so no cluster is needed; the age-ordering half of the rule is pinned in
|
||||
/// <see cref="ClusterActiveNodeTests"/> against a real two-node cluster.
|
||||
/// </summary>
|
||||
public sealed class ActiveRoleResolutionTests
|
||||
{
|
||||
[Fact]
|
||||
public void EmptyPreference_IsUnscoped()
|
||||
{
|
||||
// Unscoped: every Up member competes for one active slot (the ScadaBridge central pattern).
|
||||
Assert.Null(ClusterActiveNode.ResolveActiveRole(["admin", "driver"], []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FusedNode_AnswersForTheHighestPrecedenceRoleItCarries()
|
||||
{
|
||||
Assert.Equal(
|
||||
"admin",
|
||||
ClusterActiveNode.ResolveActiveRole(["admin", "driver", "cluster-MAIN"], ["admin", "driver"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleRoleNode_AnswersForThatRole()
|
||||
{
|
||||
// The regression the OtOpcUa fix existed for: under the old role-filtered check every
|
||||
// driver-only node reported Healthy — "or not a role member" — so all of them called
|
||||
// themselves active and no consumer could find the active node of a site cluster. lmxopcua#494.
|
||||
Assert.Equal(
|
||||
"driver",
|
||||
ClusterActiveNode.ResolveActiveRole(["driver", "cluster-SITE-A"], ["admin", "driver"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferenceOrderWins_NotTheOrderTheNodeListsItsRoles()
|
||||
{
|
||||
// Self-role order is incidental (it comes from config parsing); precedence must come from the
|
||||
// caller's list alone, or a fused node's answer would depend on how its roles were spelled.
|
||||
Assert.Equal(
|
||||
"admin",
|
||||
ClusterActiveNode.ResolveActiveRole(["driver", "admin"], ["admin", "driver"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeCarryingNoneOfThePreferredRoles_ResolvesToNull()
|
||||
{
|
||||
// Reachable, not merely defensive: OtOpcUa's RoleParser admits a dev-only or cluster-role-only
|
||||
// node. Such a node owns no active work — the health check maps this to Unhealthy so it stays
|
||||
// out of the active pool.
|
||||
Assert.Null(ClusterActiveNode.ResolveActiveRole(["dev", "cluster-MAIN"], ["admin", "driver"]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the cluster-view <c>data</c> dictionary that <see cref="AkkaClusterHealthCheck"/> publishes
|
||||
/// from 0.2.0 on (consumed by the family overview dashboard to show each cluster's current leader).
|
||||
/// Uses a real single-node self-joined cluster via <see cref="TestKit"/> — the values come off
|
||||
/// <see cref="Cluster"/>, which cannot be faked, so a formed cluster is the only honest fixture.
|
||||
/// </summary>
|
||||
public sealed class AkkaClusterDataTests : TestKit
|
||||
{
|
||||
private const string ClusterConfig = """
|
||||
akka {
|
||||
actor.provider = cluster
|
||||
remote.dot-netty.tcp {
|
||||
hostname = "127.0.0.1"
|
||||
port = 0
|
||||
}
|
||||
cluster {
|
||||
roles = ["driver", "admin"]
|
||||
# Keep the single-node self-join deterministic and fast in-test.
|
||||
min-nr-of-members = 1
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static readonly TimeSpan FormationTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public AkkaClusterDataTests()
|
||||
: base(ClusterConfig, nameof(AkkaClusterDataTests))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Self-joins the test node and waits until it is Up with a converged leader.</summary>
|
||||
private Cluster FormSingleNodeCluster()
|
||||
{
|
||||
var cluster = Cluster.Get(Sys);
|
||||
cluster.Join(cluster.SelfAddress);
|
||||
AwaitCondition(
|
||||
() => cluster.SelfMember.Status == MemberStatus.Up && cluster.State.Leader is not null,
|
||||
FormationTimeout);
|
||||
return cluster;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClusterData_SingleNodeCluster_ReportsSelfAsLeader()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
|
||||
var data = AkkaClusterHealthCheck.BuildClusterData(cluster);
|
||||
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["leader"]));
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), Assert.IsType<string>(data["selfAddress"]));
|
||||
Assert.Equal(1, Assert.IsType<int>(data["memberCount"]));
|
||||
Assert.Equal(0, Assert.IsType<int>(data["unreachableCount"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildClusterData_RolesRoundTripSorted()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
|
||||
var roles = Assert.IsType<string[]>(AkkaClusterHealthCheck.BuildClusterData(cluster)["selfRoles"]);
|
||||
|
||||
// Sorted, so the payload is stable across restarts; the configured roles survive verbatim.
|
||||
Assert.Equal(new[] { "admin", "driver" }, roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealth_FormedCluster_HealthyResultCarriesClusterData()
|
||||
{
|
||||
var cluster = FormSingleNodeCluster();
|
||||
var provider = new ServiceCollection().AddSingleton(Sys).BuildServiceProvider();
|
||||
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(cluster.SelfAddress.ToString(), result.Data["leader"]);
|
||||
Assert.Equal(1, result.Data["memberCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealth_NoActorSystem_CarriesNoData()
|
||||
{
|
||||
// The startup-safety paths stay description-only: an empty data dictionary is what makes the
|
||||
// writer omit the "data" key entirely, so a not-yet-started node's payload is unchanged.
|
||||
var provider = new ServiceCollection().BuildServiceProvider();
|
||||
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
Assert.Empty(result.Data);
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,8 @@ public sealed class AkkaClusterStatusPolicyTests
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
// No cluster to describe → no data, so the emitted payload stays the pre-0.2.0 shape.
|
||||
Assert.Empty(result.Data);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Akka.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
|
||||
namespace ZB.MOM.WW.Health.Akka.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <b>which</b> node the active tier names, against a real two-node cluster deliberately built
|
||||
/// so the oldest member is <i>not</i> the lowest-addressed one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The divergence is the whole point. Akka offers two different "first" members:
|
||||
/// <c>ClusterState.Leader</c> / <c>RoleLeader</c> is the <b>lowest-addressed</b> Up member
|
||||
/// (host, then port — nothing to do with time), while <b>oldest</b> is the member with the
|
||||
/// lowest up-number, which is what <c>ClusterSingletonManager</c> uses to place singletons.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// They coincide on a freshly-formed cluster, which is why every single-node and happy-path
|
||||
/// test missed it and why the leader-based check survived to 0.2.1. They diverge the moment a
|
||||
/// node restarts: the restarted node rejoins as the youngest while keeping its address, so if
|
||||
/// it holds the lower address it becomes leader while the singletons — and every piece of work
|
||||
/// they own — stay on the other node. This fixture reproduces that state directly instead of
|
||||
/// waiting for a restart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClusterActiveNodeTests : IAsyncLifetime
|
||||
{
|
||||
// Fixed, unusual ports: the test needs a deterministic address ordering, which port 0 cannot give.
|
||||
// Distinct from OtOpcUa's equivalent fixture (19_530/19_531) so the two suites can run at once.
|
||||
private const int OldestPort = 19_541; // joins FIRST -> oldest, but the HIGHER address
|
||||
private const int YoungestPort = 19_540; // joins SECOND -> cluster leader, the LOWER address
|
||||
|
||||
private const string SystemName = "health-active-node";
|
||||
|
||||
private ActorSystem? _oldest;
|
||||
private ActorSystem? _youngest;
|
||||
|
||||
private static Config NodeConfig(int port, string roles) => ConfigurationFactory.ParseString($$"""
|
||||
akka {
|
||||
loglevel = "WARNING"
|
||||
actor.provider = "Akka.Cluster.ClusterActorRefProvider, Akka.Cluster"
|
||||
remote.dot-netty.tcp {
|
||||
hostname = "127.0.0.1"
|
||||
port = {{port}}
|
||||
}
|
||||
cluster {
|
||||
seed-nodes = ["akka.tcp://{{SystemName}}@127.0.0.1:{{OldestPort}}"]
|
||||
roles = [{{roles}}]
|
||||
min-nr-of-members = 1
|
||||
run-coordinated-shutdown-when-down = off
|
||||
downing-provider-class = ""
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// Order matters: the seed forms the cluster and is therefore the oldest member. The oldest
|
||||
// node is fused (admin + driver); the younger one is driver-only, so the role-scoped
|
||||
// assertions below can distinguish "oldest of this role" from "oldest overall".
|
||||
_oldest = ActorSystem.Create(SystemName, NodeConfig(OldestPort, "\"admin\", \"driver\""));
|
||||
await WaitForUpAsync(_oldest, expectedMembers: 1);
|
||||
|
||||
_youngest = ActorSystem.Create(SystemName, NodeConfig(YoungestPort, "\"driver\""));
|
||||
await WaitForUpAsync(_oldest, expectedMembers: 2);
|
||||
await WaitForUpAsync(_youngest, expectedMembers: 2);
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_youngest is not null) await _youngest.Terminate();
|
||||
if (_oldest is not null) await _oldest.Terminate();
|
||||
}
|
||||
|
||||
private static async Task WaitForUpAsync(ActorSystem system, int expectedMembers)
|
||||
{
|
||||
var cluster = Cluster.Get(system);
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(30);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cluster.State.Members.Count(m => m.Status == MemberStatus.Up) >= expectedMembers) return;
|
||||
await Task.Delay(100);
|
||||
}
|
||||
|
||||
throw new TimeoutException(
|
||||
$"cluster on port {cluster.SelfAddress.Port} never saw {expectedMembers} Up members "
|
||||
+ $"(saw {cluster.State.Members.Count(m => m.Status == MemberStatus.Up)})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanity check on the fixture itself: if the two orderings did not actually diverge, every
|
||||
/// assertion below would pass for the wrong reason.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Fixture_actually_produces_divergent_age_and_address_ordering()
|
||||
{
|
||||
var state = Cluster.Get(_oldest!).State;
|
||||
|
||||
Assert.Equal(YoungestPort, state.Leader!.Port);
|
||||
Assert.Equal(YoungestPort, state.RoleLeader("driver")!.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestUpMember_IsTheOldest_NotTheLeader()
|
||||
{
|
||||
var members = Cluster.Get(_oldest!).State.Members;
|
||||
|
||||
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members)!.Address.Port);
|
||||
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members, "driver")!.Address.Port);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldestUpMember_IsRoleScoped()
|
||||
{
|
||||
var members = Cluster.Get(_oldest!).State.Members;
|
||||
|
||||
// Only the fused node carries admin, so it owns the admin slot even though it is not the
|
||||
// lowest-addressed member.
|
||||
Assert.Equal(OldestPort, ClusterActiveNode.OldestUpMember(members, "admin")!.Address.Port);
|
||||
|
||||
// A role nobody carries has no owner.
|
||||
Assert.Null(ClusterActiveNode.OldestUpMember(members, "no-such-role"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfIsActive_IsTrueOnlyOnTheOldestNode()
|
||||
{
|
||||
Assert.True(ClusterActiveNode.SelfIsActive(Cluster.Get(_oldest!)));
|
||||
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfIsActive_RoleScoped_YoungestOwnsNoSlot()
|
||||
{
|
||||
// Driver-scoped: the fused node is still the oldest driver, so the driver-only node is standby.
|
||||
Assert.True(ClusterActiveNode.SelfIsActive(Cluster.Get(_oldest!), "driver"));
|
||||
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!), "driver"));
|
||||
|
||||
// Admin-scoped: the driver-only node does not carry admin, so it can never be the admin active
|
||||
// node — even though it is the cluster leader.
|
||||
Assert.False(ClusterActiveNode.SelfIsActive(Cluster.Get(_youngest!), "admin"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_IsHealthyOnTheOldestAndUnhealthyOnTheLeader()
|
||||
{
|
||||
var onOldest = await CheckAsync(_oldest!, new ActiveNodeHealthCheckOptions());
|
||||
var onYoungest = await CheckAsync(_youngest!, new ActiveNodeHealthCheckOptions());
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, onOldest.Status);
|
||||
|
||||
// The node the old leader-based check would have called active. Exactly one 200 per cluster is
|
||||
// the property an orchestrator's active-tier routing depends on.
|
||||
Assert.Equal(HealthStatus.Unhealthy, onYoungest.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_DataNamesTheActiveNode_EvenFromAStandby()
|
||||
{
|
||||
// A standby reporting *who* is active is what lets a dashboard render the pair from either
|
||||
// node's payload alone (the 0.2.0 per-entry data object).
|
||||
var result = await CheckAsync(_youngest!, new ActiveNodeHealthCheckOptions());
|
||||
|
||||
Assert.Equal($"akka.tcp://{SystemName}@127.0.0.1:{OldestPort}", result.Data["activeNode"]);
|
||||
Assert.Equal($"akka.tcp://{SystemName}@127.0.0.1:{YoungestPort}", result.Data["selfAddress"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RolePreference_FusedNodeAnswersForAdmin()
|
||||
{
|
||||
var options = new ActiveNodeHealthCheckOptions { RolePreference = ["admin", "driver"] };
|
||||
|
||||
var onOldest = await CheckAsync(_oldest!, options);
|
||||
var onYoungest = await CheckAsync(_youngest!, options);
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, onOldest.Status);
|
||||
Assert.Equal("admin", onOldest.Data["activeRole"]);
|
||||
|
||||
// Resolves to driver (it has no admin role) and loses to the older fused node.
|
||||
Assert.Equal(HealthStatus.Unhealthy, onYoungest.Status);
|
||||
Assert.Equal("driver", onYoungest.Data["activeRole"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_NodeOwningNoneOfThePreferredRoles_IsUnhealthyByDefault()
|
||||
{
|
||||
var options = new ActiveNodeHealthCheckOptions { RolePreference = ["no-such-role"] };
|
||||
|
||||
var result = await CheckAsync(_oldest!, options);
|
||||
|
||||
// Not "not applicable ⇒ Healthy": a node that owns no active work must stay out of the pool.
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("carries none of the active roles", result.Description);
|
||||
Assert.DoesNotContain("activeRole", result.Data.Keys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_NoActiveRoleStatus_IsOverridable()
|
||||
{
|
||||
var options = new ActiveNodeHealthCheckOptions
|
||||
{
|
||||
RolePreference = ["no-such-role"],
|
||||
NoActiveRoleStatus = HealthStatus.Healthy,
|
||||
};
|
||||
|
||||
Assert.Equal(HealthStatus.Healthy, (await CheckAsync(_oldest!, options)).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_IsActiveOnlyOnTheOldestNode()
|
||||
{
|
||||
Assert.True(new AkkaActiveNodeGate(ProviderFor(_oldest!)).IsActiveNode);
|
||||
Assert.False(new AkkaActiveNodeGate(ProviderFor(_youngest!)).IsActiveNode);
|
||||
|
||||
// Role-scoped: the driver-only node cannot gate admin work.
|
||||
Assert.False(new AkkaActiveNodeGate(ProviderFor(_youngest!), "admin").IsActiveNode);
|
||||
}
|
||||
|
||||
private static IServiceProvider ProviderFor(ActorSystem system) =>
|
||||
new ServiceCollection().AddSingleton(system).BuildServiceProvider();
|
||||
|
||||
private static async Task<HealthCheckResult> CheckAsync(
|
||||
ActorSystem system,
|
||||
ActiveNodeHealthCheckOptions options)
|
||||
{
|
||||
var check = new ActiveNodeHealthCheck(ProviderFor(system), options);
|
||||
var context = new HealthCheckContext
|
||||
{
|
||||
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
|
||||
};
|
||||
return await check.CheckHealthAsync(context);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
|
||||
@@ -22,8 +22,11 @@ public sealed class ResponseWriterTests
|
||||
{
|
||||
private readonly HealthCheckResult _result;
|
||||
|
||||
public StubHealthCheck(HealthStatus status, string? description = null) =>
|
||||
_result = new HealthCheckResult(status, description);
|
||||
public StubHealthCheck(
|
||||
HealthStatus status,
|
||||
string? description = null,
|
||||
IReadOnlyDictionary<string, object>? data = null) =>
|
||||
_result = new HealthCheckResult(status, description, exception: null, data: data);
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
@@ -31,12 +34,14 @@ public sealed class ResponseWriterTests
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> GetReadyAsync(
|
||||
HealthStatus status, string? description = "db reachable")
|
||||
HealthStatus status,
|
||||
string? description = "db reachable",
|
||||
IReadOnlyDictionary<string, object>? data = null)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("db", new StubHealthCheck(status, description), tags: new[] { ZbHealthTags.Ready });
|
||||
.AddCheck("db", new StubHealthCheck(status, description, data), tags: new[] { ZbHealthTags.Ready });
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapZbHealth();
|
||||
@@ -84,6 +89,51 @@ public sealed class ResponseWriterTests
|
||||
Assert.Equal(JsonValueKind.Null, description.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_EntryWithData_EmitsDataObject_WithVerbatimKeys()
|
||||
{
|
||||
// The envelope properties are camelCased by the naming policy, but a check's own data keys are
|
||||
// dictionary keys and must survive verbatim — the dashboard reads them by exact name
|
||||
// (entries["akka-cluster"].data.leader), so a naming policy applied here would silently break
|
||||
// the contract. Values may be any JSON-friendly type (string / int / string[]).
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
["leader"] = "akka.tcp://sys@node-1:4053",
|
||||
["memberCount"] = 2,
|
||||
["selfRoles"] = new[] { "driver", "site-a" },
|
||||
};
|
||||
|
||||
var response = await GetReadyAsync(HealthStatus.Healthy, data: data);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var entryData = doc.RootElement.GetProperty("entries").GetProperty("db").GetProperty("data");
|
||||
|
||||
Assert.Equal("akka.tcp://sys@node-1:4053", entryData.GetProperty("leader").GetString());
|
||||
Assert.Equal(2, entryData.GetProperty("memberCount").GetInt32());
|
||||
Assert.Equal(
|
||||
new[] { "driver", "site-a" },
|
||||
entryData.GetProperty("selfRoles").EnumerateArray().Select(static e => e.GetString()).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_EntryWithoutData_OmitsDataKeyEntirely()
|
||||
{
|
||||
// The 0.2.0 non-breaking invariant: a check that publishes no data must produce exactly the
|
||||
// pre-0.2.0 body. Asserted on the raw JSON string so a `"data": null` regression (which
|
||||
// TryGetProperty would still accept as "present") fails here.
|
||||
var response = await GetReadyAsync(HealthStatus.Healthy);
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.DoesNotContain("\"data\"", body, StringComparison.Ordinal);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var db = doc.RootElement.GetProperty("entries").GetProperty("db");
|
||||
Assert.False(db.TryGetProperty("data", out _), "data key must be absent when the check set none");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.3.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- NU1510 (package-pruning advice): the Replication and Tests projects carry a
|
||||
Microsoft.AspNetCore.App FrameworkReference AND explicit PackageReferences to
|
||||
in-framework packages (Hosting/Logging/DI/Configuration), as required by the
|
||||
library layout. The advice is benign; keep the explicit refs and silence it. -->
|
||||
<NoWarn>$(NoWarn);NU1510</NoWarn>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Embed the repo README in each packable nupkg (ignored by non-packable projects). -->
|
||||
<ItemGroup Condition="'$(IsPackable)' == 'true'">
|
||||
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" Visible="false" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<!-- Enable transitive pinning so the security override below (SQLitePCLRaw native lib) takes effect. -->
|
||||
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<!-- Transitive security pin: Microsoft.Data.Sqlite 10.0.7 pulls SQLitePCLRaw.lib.e_sqlite3 2.1.11,
|
||||
which carries advisory GHSA-2m69-gcr7-jv3q (NU1903). Pin the patched 2.1.12 native lib. -->
|
||||
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageVersion Include="Grpc.Core.Api" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Tools" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageVersion Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user