Compare commits
136 Commits
18e4b70572
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f47d4e1030 | |||
| 7ae25f8510 | |||
| 05cc62aab3 | |||
| ae0ccc9a3a | |||
| 544a6ddb77 | |||
| 26ba1c7215 | |||
| 5f75cd4dab | |||
| 899efc2cbf | |||
| fbf0f23e76 | |||
| e47ecacb0d | |||
| 69fb6cb077 | |||
| a29f226a70 | |||
| 3fa77b70fc | |||
| 46c4bfae31 | |||
| b754873a44 | |||
| 8d91a3021d | |||
| 8145d79dc6 | |||
| e191893738 | |||
| 563cf44c60 | |||
| d18c121033 | |||
| a104372eac | |||
| 80e4d59209 | |||
| 229b82efbc |
@@ -6,11 +6,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
|
||||
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
|
||||
directories under `~/Desktop/`**. It now also **hosts four pieces of source itself** —
|
||||
directories under `~/Desktop/`**. It now also **hosts six pieces of source itself** —
|
||||
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library, the shared
|
||||
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit, the shared
|
||||
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, and the shared
|
||||
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library — all the realized output of their
|
||||
[`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) health-check library, the shared
|
||||
[`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) observability library, the shared
|
||||
[`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) config-validation library, and the new
|
||||
[`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) Galaxy browse library — all the realized output of their
|
||||
respective component normalizations (see [Component normalization](#component-normalization)).
|
||||
The point of this file is to give a high-level scan of each sister project — its purpose,
|
||||
location, stack, and primary commands — so a fresh Claude Code session can orient across
|
||||
@@ -29,9 +31,10 @@ own `CLAUDE.md` for the full picture. See [Refreshing this index](#refreshing-th
|
||||
|
||||
| Project | Location | Stack | Repo | Summary |
|
||||
|---|---|---|---|---|
|
||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes AVEVA System Platform (Wonderware) Galaxy tags as an OPC UA address space. Galaxy access flows through an in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. |
|
||||
| **OtOpcUa** | `~/Desktop/OtOpcUa` | .NET 10, OPC UA, gRPC | `gitea.dohertylan.com/dohertj2/lmxopcua` | OPC UA server that exposes industrial data sources under a **unified Equipment-based address space** — native-protocol drivers (Modbus, S7, AB CIP/Legacy, TwinCAT, FOCAS, OpcUaClient) **and AVEVA System Platform (Wonderware) Galaxy, now a standard Equipment-kind driver** (the old SystemPlatform mirror / alias-tag model was retired ~2026-06-12). Galaxy access flows through the in-process `GalaxyDriver` → gRPC → the **mxaccessgw** gateway. Surfaces live read + authorized write, native OPC UA Part 9 alarms, and server-side HistoryRead. |
|
||||
| **MxAccessGateway** (`mxaccessgw`) | `~/Desktop/MxAccessGateway` | .NET 10 gateway (x64) + .NET 4.8 worker (**x86**), gRPC | `gitea.dohertylan.com/dohertj2/mxaccessgw` | gRPC gateway giving modern clients full MXAccess parity without loading 32-bit COM. Two-process: gateway (ASP.NET Core gRPC + Blazor dashboard) + per-session x86 worker that owns the MXAccess COM STA. **OtOpcUa depends on this.** |
|
||||
| **ScadaBridge** | `~/Desktop/ScadaBridge` | .NET 10, Akka.NET, Docker | _git_ | Full implementation of the distributed SCADA platform — hub-and-spoke (1 central cluster + N site clusters). Projects prefixed `ZB.MOM.WW.ScadaBridge.*`; solution `ZB.MOM.WW.ScadaBridge.slnx`. Ships `src/`, `tests/`, `docker/` topology, and the design docs that are the spec. |
|
||||
| **HistorianGateway** | `~/Desktop/HistorianGateway` | .NET 10 x64, gRPC, Blazor | `gitea.dohertylan.com/dohertj2/historiangw` | Single-process gRPC sidecar exposing (1) full read/write API to the AVEVA Historian (5 gRPC services; 15 retrieval modes; historical/backfill writes; tag-config lifecycle; SQL live-value path; store-forward + redundancy resilience; all default-disabled) and (2) read-only Galaxy object-hierarchy browse via the shared `ZB.MOM.WW.GalaxyRepository` lib (consumed as a Gitea-feed package). No COM, no x86 worker. **Dev:** two plaintext endpoints from `appsettings.Development.json` — dashboard on `:5220` (HTTP/1.1), gRPC h2c on `:5221`. **Production:** single `Kestrel:Endpoints:Https` endpoint with `Protocols: Http1AndHttp2` multiplexes dashboard + gRPC over one TLS port (ALPN); warn-only if no TLS endpoint configured (valid behind a reverse proxy / Kubernetes ingress; the warn predicate covers any non-Development environment, i.e. Production + Staging). In a non-Development environment the gateway also logs warn-only **production-readiness** checks (pending.md D2/D3) — relative runtime-artifact paths + secret hygiene (`ApiKeys:Mode=Disabled`, empty/dev-placeholder pepper, dev-placeholder LDAP password). Vendors `AVEVA.Historian.Client` from `histsdk`. Store-forward uses a crash-safe FasterLog append-only outbox (`Microsoft.FASTER.Core` 2.6.5; `CommitMode` PerEntry/Periodic), not SQLite. **Handshake amortization (pending.md A1) done + live-validated** — a default-on leased-session pool (`Historian:SessionPool`) reuses pre-authenticated sessions across reads/writes/status ops/tag-browse/metadata (~4.7× measured; probe and blocks stay per-call), with a `<~15 s` keepalive + reactive re-auth, surfaced via a `PooledHistorianClient` facade so services are unchanged; the `HistorianSession` primitive is upstream in the vendored `AVEVA.Historian.Client` (re-vendored @ `be60d0b`); browse/metadata broadened on branch `feat/amortization-broadening`. **`SendEvent` is also amortized** via a **separate, parallel event-session pool** (`Historian:EventSessionPool`, default-on; v8/ECDH auth — kept distinct from the v6 pool), warranted by a GREEN v8 Event-session reuse spike (~10–16×); `ReadEvents` stays per-call / gated (C2). The full offline suite is green on macOS (0 warnings); the env-gated live historian + Galaxy integration suite exercises the amortized path and otherwise skips without a live server. |
|
||||
|
||||
## Cross-project relationships
|
||||
|
||||
@@ -83,8 +86,10 @@ the gateway uses `MxGateway.*`). The common subject is **AVEVA System Platform (
|
||||
`GalaxyRepositoryClient` for the static hierarchy, and an MXAccess session
|
||||
(`MxCommand`/`MxEvent` protos) for live read/write/subscribe. A `DeployWatcher` polls the
|
||||
gateway's deploy-event signal to rebuild the OPC UA address space on Galaxy redeploy.
|
||||
OtOpcUa's job is purely a **protocol bridge**: it republishes Galaxy as an OPC UA address
|
||||
space for *any* OPC UA client.
|
||||
OtOpcUa's job is a **protocol bridge**: it republishes Galaxy — now bound as a *standard
|
||||
Equipment-kind driver* alongside its native-protocol drivers, not a special SystemPlatform
|
||||
mirror — as an OPC UA address space (live values, Part 9 alarms, HistoryRead) for *any* OPC
|
||||
UA client.
|
||||
- **ScadaBridge → OPC UA** (OPC UA client). ScadaBridge's DCL has an OPC UA adapter that
|
||||
collects data and mirrors native OPC UA Alarms & Conditions. OtOpcUa is exactly such a
|
||||
server, so ScadaBridge can ingest Wonderware data **indirectly via OtOpcUa**.
|
||||
@@ -100,15 +105,21 @@ the gateway uses `MxGateway.*`). The common subject is **AVEVA System Platform (
|
||||
- ScadaBridge has **two paths** to the same Wonderware data: (1) OPC UA → OtOpcUa →
|
||||
gateway, or (2) MxGateway adapter → gateway directly. Path 1 gives standards-based OPC UA
|
||||
decoupling; path 2 gives a more direct/native feed.
|
||||
- **HistorianGateway is a new, independent sidecar** (no runtime coupling to the three above).
|
||||
It reaches the Historian via its vendored gRPC client and the Galaxy Repository SQL DB directly,
|
||||
not through `mxaccessgw`. It consumes the shared `ZB.MOM.WW.GalaxyRepository` lib
|
||||
(cross-repo `ProjectReference`). Any client that needs Historian data or Galaxy browse can
|
||||
target HistorianGateway independently; it is not a dependency of OtOpcUa or ScadaBridge today.
|
||||
- Coupling is loose: each repo references the others only as **sibling context** (the
|
||||
`## Sister Projects` note in ScadaBridge's own `CLAUDE.md` lists `MxAccessGateway` and
|
||||
`OtOpcUa` with their Gitea URLs but states they are *not part of its solution*).
|
||||
- **The break surface is the wire contracts, not code.** Because coupling is by network
|
||||
protocol, the things that break across repo boundaries are: the gateway's `.proto` files
|
||||
(`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`), and the
|
||||
OPC UA address-space shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model).
|
||||
Changes to any of these must be coordinated across the affected repos — a green build in
|
||||
one repo does not prove the others still interoperate.
|
||||
(`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`), the
|
||||
`historian_gateway.v1` proto (HistorianGateway's own contract), and the OPC UA address-space
|
||||
shape OtOpcUa publishes (browse paths, node IDs, A&C alarm model). Changes to any of these
|
||||
must be coordinated across the affected repos — a green build in one repo does not prove the
|
||||
others still interoperate.
|
||||
|
||||
## Component normalization
|
||||
|
||||
@@ -119,11 +130,13 @@ each project's **code-verified current state**, and the **gaps** between. See
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
| UI Theme (layout / tokens / components) | Adopted (lib `0.2.0`; all 3 apps, merged to **local default** + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
|
||||
| Audit (event model + writer seam) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
|
||||
| Audit (event model + writer seam) | Adopted (lib `0.1.0`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Audit` lib | [`components/audit/`](components/audit/) | [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/) |
|
||||
| Galaxy Repository (object-hierarchy SQL browse + gRPC service) | Built (lib `0.1.0`, **published to the Gitea feed**; consumed by HistorianGateway as a feed `PackageReference`) | Shared `ZB.MOM.WW.GalaxyRepository` lib | _(design in histsdk + design doc 2026-06-23)_ | [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/) |
|
||||
|
||||
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
|
||||
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
|
||||
@@ -135,7 +148,14 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB
|
||||
(its own nested git repo; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
|
||||
172 tests; `dotnet pack` → 4 nupkgs @ 0.1.0). The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
|
||||
**Adopted across all three apps on 2026-06-02** (auth GAPS #1–#8) on each repo's `feat/adopt-zb-auth` branch —
|
||||
committed + reviewed, then **fast-forward-merged into the repo's local default (main/master) and PUSHED to origin
|
||||
(gitea) on 2026-06-03** (in sync; the `feat/*` branches kept locally as history). Cutover: shared `Auth.Ldap`,
|
||||
`Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper<TRole>` seam,
|
||||
`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
|
||||
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: OtOpcUa `0.1.1`,
|
||||
MxGateway `0.1.2`, ScadaBridge `0.1.3`. Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) +
|
||||
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
|
||||
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
|
||||
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
|
||||
|
||||
@@ -147,10 +167,18 @@ backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + wi
|
||||
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
|
||||
|
||||
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
|
||||
(.NET 10 Razor Class Library; single package; 32 bUnit tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
||||
The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md).
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
||||
(.NET 10 Razor Class Library; single package; 44 bUnit tests; `dotnet pack` → 1 nupkg @ 0.2.0,
|
||||
**published to the Gitea feed**). The build plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md);
|
||||
the adoption plan at [`docs/plans/2026-06-03-ui-theme-adoption.md`](docs/plans/2026-06-03-ui-theme-adoption.md).
|
||||
**Adopted across all three apps on 2026-06-03** (full canonical cutover, SPEC §7) on each repo's
|
||||
`feat/adopt-zb-theme` branch — committed + spec/code-reviewed, then **fast-forward-merged into each repo's local
|
||||
default (master/main) and PUSHED to origin (gitea)** (in sync; `feat/*` kept locally as history): OtOpcUa
|
||||
→`lmxopcua` `master`@`11de14d`, ScadaBridge `main`@`58352a6`, MxGateway→`mxaccessgw` `main`@`73e54e2`. The `0.1.0 → 0.2.0` bump first promoted nav-expand persistence
|
||||
into the kit (`NavRailSection.Key`/`data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
|
||||
`<ThemeScripts/>`), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
|
||||
island retired); MxGateway additionally gained a net-new Blazor `<LoginCard>` `/login` page over its existing
|
||||
hardened endpoint. Per-app result in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
|
||||
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
|
||||
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
|
||||
|
||||
@@ -181,12 +209,41 @@ enrichers, and redaction policies.
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/)
|
||||
(.NET 10; 2 packages — `ZB.MOM.WW.Telemetry`, `ZB.MOM.WW.Telemetry.Serilog`; 19 tests;
|
||||
`dotnet pack` → 2 nupkgs @ 0.1.0). **MxAccessGateway logging adopted** (MEL → Serilog migration done on
|
||||
its own branch) — the one in-pass adoption. Broader OtOpcUa and ScadaBridge telemetry adoption is
|
||||
follow-on, tracked in [`components/observability/GAPS.md`](components/observability/GAPS.md).
|
||||
`dotnet pack` → 2 nupkgs @ 0.1.0). **Adopted across all three apps on 2026-06-01** (branch
|
||||
`feat/adopt-zb-telemetry` per repo, behaviour-preserving): `AddZbTelemetry` (Resource + standard
|
||||
instrumentation + Prometheus `/metrics`) everywhere; OtOpcUa + MxGateway on `AddZbSerilog` (MxGateway's
|
||||
MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done
|
||||
beforehand despite an earlier claim); ScadaBridge keeps its `LoggerConfigurationFactory` (min-level
|
||||
governance) and only adds the shared `TraceContextEnricher`. Deferred: MxGateway `ms`→`s` + Meter
|
||||
rename, ScadaBridge app instruments + Site-node HTTP/1.1 metrics listener, OTLP wiring. Per-repo
|
||||
result tracked in [`components/observability/GAPS.md`](components/observability/GAPS.md).
|
||||
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
|
||||
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
|
||||
|
||||
The configuration component is fully populated: a normalized [`spec`](components/configuration/spec/SPEC.md), a
|
||||
[`shared-contract`](components/configuration/shared-contract/ZB.MOM.WW.Configuration.md), three
|
||||
[`current-state`](components/configuration/current-state/) docs, and an adoption [`GAPS`](components/configuration/GAPS.md)
|
||||
backlog. Shared = the `IValidateOptions<T>` failure-accumulation base (`OptionsValidatorBase<T>`) +
|
||||
reusable rule primitives (`ValidationBuilder`: port / host:port / required / positive-duration / one-of /
|
||||
min-count) + `AddValidatedOptions<TOptions,TValidator>()` (bind + validate + `ValidateOnStart`) + the
|
||||
pre-host `ConfigPreflight` aggregator (generalizes ScadaBridge's `StartupValidator`, byte-compatible
|
||||
message); left per-project = each app's options classes + domain rules, and OtOpcUa's
|
||||
draft/generation-content validation (DB-side `sp_ValidateDraft`; its C# `DraftValidator` is dormant).
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/)
|
||||
(.NET 10; single package `ZB.MOM.WW.Configuration`; 27 tests; `dotnet pack` → 1 nupkg @ 0.1.0).
|
||||
The implementation plan is at
|
||||
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
|
||||
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
|
||||
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes; the package was first
|
||||
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
|
||||
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator` → `ConfigPreflight`
|
||||
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in
|
||||
[`components/configuration/GAPS.md`](components/configuration/GAPS.md).
|
||||
Build/test from `ZB.MOM.WW.Configuration/`: `dotnet test`. Consumer matrix: all three apps consume the
|
||||
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator` →
|
||||
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
|
||||
|
||||
The audit component is fully populated: a normalized [`spec`](components/audit/spec/SPEC.md), an
|
||||
[`event-model`](components/audit/spec/EVENT-MODEL.md) reference, a
|
||||
[`shared-contract`](components/audit/shared-contract/ZB.MOM.WW.Audit.md), three
|
||||
@@ -200,10 +257,39 @@ principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam conv
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
|
||||
(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
|
||||
19 tests; `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
|
||||
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/audit/GAPS.md`](components/audit/GAPS.md).
|
||||
**Adopted across all three apps on 2026-06-02** (audit GAPS #1–#6) on each repo's `feat/adopt-zb-audit` branch
|
||||
(stacked on `feat/adopt-zb-auth`) — committed + reviewed, then **merged into the repo's local default (main/master)
|
||||
and PUSHED to origin (gitea) on 2026-06-03** (in sync). Depth =
|
||||
**DEEP adopt** (the canonical 9-field `AuditEvent` is the record everywhere; domain fields ride in `DetailsJson`).
|
||||
OtOpcUa: canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` column/migration + `ClusterAudit` fix.
|
||||
MxGateway: new canonical SQLite `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter.
|
||||
**ScadaBridge: a full audit-subsystem re-architecture** (the program's largest task) — canonical record everywhere via a
|
||||
deterministic codec; site SQLite split into `audit_event` + an `audit_forward_state` forwarding sidecar; central
|
||||
partitioned `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical`
|
||||
migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at authenticated emit sites (per-app
|
||||
`IAuditActorAccessor`). Per-repo detail in [`components/audit/GAPS.md`](components/audit/GAPS.md) +
|
||||
`docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`.
|
||||
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
|
||||
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge each map their own audit record/seam
|
||||
onto the canonical type at the emit boundary).
|
||||
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
|
||||
|
||||
The Galaxy Repository component normalizes the **Galaxy object-hierarchy SQL browse + reusable gRPC service**
|
||||
that was previously embedded in `mxaccessgw`. Shared = canonical `galaxy_repository.v1` proto (wire-compatible
|
||||
with `mxaccessgw`'s existing contract so OtOpcUa's `GalaxyRepositoryClient` is unaffected), the SQL browse
|
||||
provider (`HierarchySql` / `AttributesSql` validated reverse-engineered queries), in-memory hierarchy cache +
|
||||
snapshot + deploy-poll refresh `BackgroundService`, `GalaxyHierarchyProjector`, and `AddZbGalaxyRepository` /
|
||||
`MapZbGalaxyRepository` DI extension. Left per-consumer = section path, subtree auth filtering, and any
|
||||
app-specific paging defaults.
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/)
|
||||
(.NET 10; single package `ZB.MOM.WW.GalaxyRepository`; `dotnet pack` → 1 nupkg @ 0.1.0, **published to
|
||||
the Gitea NuGet feed** `gitea.dohertylan.com/api/packages/dohertj2/nuget`). The design doc is at
|
||||
[`docs/plans/2026-06-23-historian-gateway-design.md`](docs/plans/2026-06-23-historian-gateway-design.md) (§10, component 1).
|
||||
**Consumed by HistorianGateway** as a `PackageReference` from that Gitea feed, pinned at `0.1.0` (originally a
|
||||
cross-repo `ProjectReference` to this scadaproj tree; switched to the feed package 2026-06-24).
|
||||
**mxaccessgw adoption is a tracked follow-on** — once adopted, mxaccessgw's inline Galaxy browse code is replaced
|
||||
by the shared lib (the `galaxy_repository.v1` wire contract is already identical, so OtOpcUa and ScadaBridge
|
||||
clients are unaffected). Build/test from `ZB.MOM.WW.GalaxyRepository/`: `dotnet test`.
|
||||
Consumer matrix: HistorianGateway (initial); mxaccessgw (follow-on adoption).
|
||||
|
||||
## Per-project primary commands
|
||||
|
||||
@@ -225,9 +311,23 @@ dotnet run --project src/MxGateway.Server/MxGateway.Server.csproj
|
||||
# ScadaBridge (~/Desktop/ScadaBridge)
|
||||
dotnet build ZB.MOM.WW.ScadaBridge.slnx
|
||||
bash docker/deploy.sh # rebuild + redeploy the 8-node cluster
|
||||
cd infra && docker compose up -d # local test services (LDAP, SQL, OPC UA, SMTP, REST, Traefik)
|
||||
cd infra && docker compose up -d # local test services (SQL, OPC UA, SMTP, REST, Traefik) — LDAP is NOT here
|
||||
|
||||
# HistorianGateway (~/Desktop/HistorianGateway)
|
||||
dotnet build ZB.MOM.WW.HistorianGateway.slnx
|
||||
dotnet test ZB.MOM.WW.HistorianGateway.slnx # unit + golden; live integration tests skip without env vars
|
||||
dotnet run --project src/ZB.MOM.WW.HistorianGateway.Server/ZB.MOM.WW.HistorianGateway.Server.csproj
|
||||
# Dev: dashboard on :5220 (HTTP/1.1), gRPC h2c on :5221 (from appsettings.Development.json)
|
||||
# Production: single Kestrel:Endpoints:Https with Protocols=Http1AndHttp2 (ALPN, one TLS port)
|
||||
# Live integration (need HISTORIAN_GRPC_HOST + HISTORIAN_GRPC_WRITE_SANDBOX_TAG + GALAXY_SQL_CONNSTR set)
|
||||
dotnet test ZB.MOM.WW.HistorianGateway.slnx --filter "Category=LiveIntegration"
|
||||
```
|
||||
|
||||
> **Shared GLAuth (all three apps + HistorianGateway):** LDAP auth for every local dev/test stack is provided by a
|
||||
> single `zb-shared-glauth` container on the Linux fixture host **`10.100.0.35:3893`**
|
||||
> (`baseDN dc=zb,dc=local`, Transport=None). Source of truth and deploy runbook:
|
||||
> [`scadaproj/infra/glauth/`](infra/glauth/) (`config.toml` + `docker-compose.yml` + `README.md`).
|
||||
|
||||
## Refreshing this index
|
||||
|
||||
This file is meant to be re-scanned when `scadaproj` is opened in Claude Code:
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/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}"
|
||||
|
||||
dotnet pack -c Release -o ./artifacts
|
||||
|
||||
dotnet nuget push "./artifacts/*.nupkg" \
|
||||
--source "$GITEA_NUGET_SOURCE" \
|
||||
--api-key "$GITEA_NUGET_KEY" \
|
||||
--skip-duplicate
|
||||
@@ -1,8 +1,9 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
|
||||
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
|
||||
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
|
||||
/// <remarks>Every inner-writer failure is swallowed — including <see cref="OperationCanceledException"/>
|
||||
/// — so the fan-out drains and the caller is never aborted, honoring the <see cref="IAuditWriter"/>
|
||||
/// "must not throw to the caller" contract even when a request-scoped cancellation token is passed.</remarks>
|
||||
public sealed class CompositeAuditWriter : IAuditWriter
|
||||
{
|
||||
private readonly IReadOnlyList<IAuditWriter> _inner;
|
||||
@@ -21,8 +22,7 @@ public sealed class CompositeAuditWriter : IAuditWriter
|
||||
foreach (var writer in _inner)
|
||||
{
|
||||
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
|
||||
catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
|
||||
catch { /* best-effort seam: a failing writer (incl. cancellation) must not stop the others or the caller */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
|
||||
/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
|
||||
/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
|
||||
/// Never throws — over-redacts (drops both DetailsJson and Target) on internal failure. The
|
||||
/// secret-field policy (which fields are sensitive) stays per-project; compose this with a project
|
||||
/// redactor as needed.
|
||||
/// </summary>
|
||||
public sealed class TruncatingAuditRedactor : IAuditRedactor
|
||||
{
|
||||
@@ -26,13 +27,15 @@ public sealed class TruncatingAuditRedactor : IAuditRedactor
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hard contract: never throw. Over-redact on internal failure.
|
||||
return rawEvent with { DetailsJson = null };
|
||||
// Hard contract: never throw, and over-redact to a STRICTLY safer event on internal
|
||||
// failure — scrub every field this redactor owns (both DetailsJson and Target).
|
||||
return rawEvent with { DetailsJson = null, Target = null };
|
||||
}
|
||||
}
|
||||
|
||||
private string? Truncate(string? value, int max)
|
||||
{
|
||||
if (max < 0) max = 0; // clamp nonsensical negative caps so a config bug fails safe, not throws
|
||||
if (value is null || value.Length <= max) return value;
|
||||
var marker = _options.TruncationMarker;
|
||||
if (marker.Length >= max) return marker[..max];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
|
||||
public sealed class TruncatingAuditRedactorOptions
|
||||
/// <summary>Immutable caps for <see cref="TruncatingAuditRedactor"/>.</summary>
|
||||
public sealed record TruncatingAuditRedactorOptions
|
||||
{
|
||||
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
|
||||
public int MaxDetailsJsonLength { get; set; } = 4096;
|
||||
public int MaxDetailsJsonLength { get; init; } = 4096;
|
||||
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
|
||||
public int MaxTargetLength { get; set; } = 512;
|
||||
public int MaxTargetLength { get; init; } = 512;
|
||||
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
|
||||
public string TruncationMarker { get; set; } = "…[truncated]";
|
||||
public string TruncationMarker { get; init; } = "…[truncated]";
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Emit and pack XML docs so consumers get IntelliSense/tooltip documentation. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -38,11 +38,32 @@ public class CompositeAuditWriterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_propagated_not_swallowed()
|
||||
public async Task Cancellation_does_not_surface_to_the_caller()
|
||||
{
|
||||
// OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
|
||||
// Per the IAuditWriter hard contract ("must not throw to the caller"), an
|
||||
// OperationCanceledException from an inner writer is swallowed like any other failure —
|
||||
// it must NOT abort the user-facing action that produced the event.
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => sut.WriteAsync(Evt()));
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count); // drain continues past the cancelled writer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_writer_list_is_a_no_op()
|
||||
{
|
||||
var sut = new CompositeAuditWriter(Array.Empty<IAuditWriter>());
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_writer_entry_is_swallowed_and_does_not_stop_the_others()
|
||||
{
|
||||
// A null inner writer faults the await; the best-effort seam swallows it (like any
|
||||
// other writer failure) and continues draining the remaining writers.
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter?[] { null, after }!);
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,30 @@ public class TruncatingAuditRedactorTests
|
||||
var result = r.Apply(Evt(new string('x', 20)));
|
||||
Assert.Equal(3, result.DetailsJson!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_max_is_treated_as_zero_and_does_not_throw()
|
||||
{
|
||||
// A negative cap is nonsensical misconfiguration. Truncate must clamp to 0 rather than
|
||||
// throw, capping the value to the empty string (plus marker handling).
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = -5, MaxTargetLength = -1, TruncationMarker = "" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 20), target: new string('y', 20)));
|
||||
Assert.Equal(string.Empty, result.DetailsJson);
|
||||
Assert.Equal(string.Empty, result.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Over_redact_fallback_scrubs_both_details_and_target_without_throwing()
|
||||
{
|
||||
// Drive the REAL TruncatingAuditRedactor.Apply into its catch branch via a reachable
|
||||
// misconfiguration (a null TruncationMarker faults inside Truncate). The over-redact
|
||||
// fallback must be strictly safer: BOTH DetailsJson AND Target scrubbed to null, no throw.
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 5, TruncationMarker = null! };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var raw = Evt(new string('x', 50), target: "sensitive target");
|
||||
var result = r.Apply(raw);
|
||||
Assert.Null(result.DetailsJson);
|
||||
Assert.Null(result.Target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.1.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Auth.Abstractions` | Auth contracts, canonical role constants, and shared types (`LdapOptions`, `LdapAuthResult`, `ILdapAuthService`, `IApiKeyStore`). No runtime dependencies beyond the BCL. | — |
|
||||
| `ZB.MOM.WW.Auth.Ldap` | LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. | `Abstractions`, `Novell.Directory.Ldap.NETStandard` |
|
||||
| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with pepper-based PBKDF2 hashing, rotation, and audit log. Includes a `MigrationHostedService` that runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
|
||||
| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core DI helpers (`AddZbAuth`), cookie defaults, claim-type constants, and `LdapOptionsValidator` registration. Wires together Ldap + ApiKeys + cookie middleware. | `Abstractions`, `Ldap`, `ApiKeys`, `Microsoft.AspNetCore.App` |
|
||||
| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with **pepper-keyed HMAC-SHA256** secret hashing, rotation, and audit log. DI wiring is `AddZbApiKeyAuth`; an opt-in `MigrationHostedService` runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
|
||||
| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core wiring for the **LDAP** provider only: `AddZbLdapAuth` (binds + start-time-validates `LdapOptions`, registers `ILdapAuthService`), plus `ZbCookieDefaults.Apply` (hardened cookie helper the consumer calls itself) and `ZbClaimTypes` constants. It does **not** wire API keys or cookie middleware — API-key DI is `AddZbApiKeyAuth` in the `ApiKeys` package. | `Abstractions`, `Ldap`, `Microsoft.AspNetCore.App` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -55,6 +55,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.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.Security;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
@@ -16,6 +18,16 @@ public sealed record LdapOptions
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <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
|
||||
/// <see langword="null"/> (the default) the LDAP client validates the server certificate against
|
||||
/// the OS trust store — it does <em>not</em> blind-accept. Supply a callback to pin a CA, validate
|
||||
/// the SAN against <see cref="Server"/>, or otherwise tighten validation. This is a code-only seam
|
||||
/// (not bound from configuration) and takes precedence over <see cref="AllowInsecure"/>.
|
||||
/// </summary>
|
||||
public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; }
|
||||
}
|
||||
|
||||
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
|
||||
|
||||
@@ -101,7 +101,10 @@ public sealed class ApiKeyAdminCommands
|
||||
|
||||
var record = new ApiKeyRecord(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
|
||||
// KeyPrefix is the bare token prefix (e.g. "mxgw"), NOT prefix_keyId — the key id is
|
||||
// already its own column. Embedding it here produced a self-referential value that
|
||||
// confused admin tooling and disagreed with the read/test paths (see Auth-005).
|
||||
KeyPrefix: _options.TokenPrefix,
|
||||
SecretHash: secretHash,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes,
|
||||
@@ -184,6 +187,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();
|
||||
|
||||
@@ -62,8 +62,24 @@ public sealed class ApiKeyVerifier(
|
||||
return Fail(ApiKeyFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
// 6. The authentication decision is already made (line 60). Recording last-used is
|
||||
// best-effort bookkeeping: a transient storage hiccup (SQLITE_BUSY past the busy-timeout,
|
||||
// disk full, DB locked by a migration) must NOT turn an otherwise-valid credential into a
|
||||
// failed auth. Swallow any non-cancellation failure so the only exception path remains
|
||||
// cancellation, as the class contract promises. Cancellation is honoured (re-thrown).
|
||||
try
|
||||
{
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: the last-used write failed, but the credential is valid. Fail open on the
|
||||
// bookkeeping (not the auth decision) rather than denying a legitimate caller.
|
||||
}
|
||||
|
||||
return new ApiKeyVerification(
|
||||
Succeeded: true,
|
||||
|
||||
@@ -20,7 +20,12 @@ public static class ScopeSerializer
|
||||
|
||||
/// <summary>Deserializes scopes from a JSON array string.</summary>
|
||||
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
|
||||
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
|
||||
/// <returns>
|
||||
/// An ordinal-compared set of scopes; empty when the input is null/blank. A malformed or
|
||||
/// non-array column (operator tampering, a partial write, a format change, or a buggy writer)
|
||||
/// fails closed to an EMPTY set rather than throwing, so a single poisoned row degrades to a
|
||||
/// zero-scope identity on the auth path instead of an unhandled <see cref="JsonException"/>.
|
||||
/// </returns>
|
||||
public static IReadOnlySet<string> Deserialize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -28,7 +33,18 @@ public static class ScopeSerializer
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
string[]? scopes;
|
||||
try
|
||||
{
|
||||
scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fail closed: a corrupt scopes column yields no scopes rather than an exception on the
|
||||
// verification hot path. The verifier's "only exception path is cancellation" contract
|
||||
// is preserved, and a key with an unreadable scope set is left with zero authority.
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -85,6 +86,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)
|
||||
{
|
||||
|
||||
@@ -5,8 +5,15 @@ 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. This is <c>2</c>, not <c>1</c>,
|
||||
/// to match the deployed databases of the donor (MxAccessGateway) this store was
|
||||
/// extracted from: that store reached its final shape via a v1→v2 history and stamps
|
||||
/// <c>version = 2</c> on disk. The final schema has been byte-identical since v1, so a
|
||||
/// single-shot create stamped as 2 interoperates with existing <c>gateway-auth.db</c>
|
||||
/// files (the migrator only refuses an on-disk version <em>newer</em> than this).
|
||||
/// </summary>
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
/// <summary>Name of the single-row table tracking the applied schema version.</summary>
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
@@ -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,10 @@ 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). The applied version is stamped
|
||||
// separately by WriteSchemaVersionAsync.
|
||||
private static async Task ApplySchemaAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -37,7 +37,14 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.Configure<LdapOptions>(config.GetSection(sectionPath));
|
||||
// Bind via the options builder and opt into start-time validation. An IValidateOptions<T>
|
||||
// otherwise only runs when the options are first materialized (IOptions<T>.Value) — which
|
||||
// here is the first login (ILdapAuthService factory below), not boot. ValidateOnStart hooks
|
||||
// the host's start-time options validation so a misconfigured directory (e.g. insecure
|
||||
// transport without AllowInsecure) fails fast at startup rather than on first login.
|
||||
services.AddOptions<LdapOptions>()
|
||||
.Bind(config.GetSection(sectionPath))
|
||||
.ValidateOnStart();
|
||||
|
||||
// Fail fast at startup on a misconfigured directory rather than on first login.
|
||||
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,8 +16,29 @@ internal sealed record LdapSearchEntry(
|
||||
/// </summary>
|
||||
internal interface ILdapConnection : IDisposable
|
||||
{
|
||||
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
|
||||
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
|
||||
/// <summary>
|
||||
/// Opens (and optionally upgrades to TLS) a connection to the given host.
|
||||
/// </summary>
|
||||
/// <param name="host">The LDAP server hostname or IP.</param>
|
||||
/// <param name="port">The LDAP server port.</param>
|
||||
/// <param name="transport">The transport security mode.</param>
|
||||
/// <param name="allowInsecure">
|
||||
/// When <see langword="true"/> AND no <paramref name="serverCertificateValidationCallback"/> is
|
||||
/// supplied, TLS server-certificate validation is bypassed (dev/test only). Ignored when a
|
||||
/// validation callback is supplied (the callback wins) or for plaintext transport.
|
||||
/// </param>
|
||||
/// <param name="timeoutMs">The connection/operation timeout in milliseconds.</param>
|
||||
/// <param name="serverCertificateValidationCallback">
|
||||
/// Optional TLS server-certificate validation callback. When <see langword="null"/>, the OS trust
|
||||
/// store is used (the client does not blind-accept).
|
||||
/// </param>
|
||||
void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback);
|
||||
|
||||
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
|
||||
void Bind(string dn, string password);
|
||||
|
||||
@@ -2,19 +2,67 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
// Disambiguate: Novell also declares a RemoteCertificateValidationCallback delegate; the seam and
|
||||
// LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback both use the BCL one.
|
||||
using RemoteCertificateValidationCallback = System.Net.Security.RemoteCertificateValidationCallback;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
|
||||
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TLS server-certificate validation: by default the underlying
|
||||
/// <c>Novell.Directory.Ldap.NETStandard</c> client validates the server certificate against the OS
|
||||
/// trust store (it does NOT blind-accept). A caller-supplied
|
||||
/// <c>RemoteCertificateValidationCallback</c> overrides that default (CA pinning / SAN checks); when
|
||||
/// none is supplied and <c>allowInsecure</c> is set, validation is bypassed for dev/test only.
|
||||
/// </remarks>
|
||||
internal sealed class NovellLdapConnection : ILdapConnection
|
||||
{
|
||||
private readonly LdapConnection _conn = new();
|
||||
private readonly LdapConnection _conn;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
/// <summary>
|
||||
/// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
|
||||
/// <paramref name="serverCertificateValidationCallback"/> wins; otherwise <paramref name="allowInsecure"/>
|
||||
/// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
|
||||
/// </summary>
|
||||
public NovellLdapConnection(
|
||||
bool allowInsecure = false,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
if (serverCertificateValidationCallback is not null)
|
||||
{
|
||||
var options = new LdapConnectionOptions()
|
||||
.ConfigureRemoteCertificateValidationCallback(serverCertificateValidationCallback);
|
||||
_conn = new LdapConnection(options);
|
||||
}
|
||||
else if (allowInsecure)
|
||||
{
|
||||
// Dev/test only: accept any server certificate. Reachable solely when an operator has set
|
||||
// AllowInsecure (rejected for plaintext-without-AllowInsecure by LdapOptionsValidator).
|
||||
var options = new LdapConnectionOptions()
|
||||
.ConfigureRemoteCertificateValidationCallback((_, _, _, _) => true);
|
||||
_conn = new LdapConnection(options);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: validate against the OS trust store (no blind-accept).
|
||||
_conn = new LdapConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback)
|
||||
{
|
||||
// The TLS-validation policy (allowInsecure / callback) is wired at construction time on the
|
||||
// LdapConnectionOptions; the per-call arguments here are accepted for seam symmetry.
|
||||
ApplyTimeout(timeoutMs);
|
||||
|
||||
// LDAPS: TLS is negotiated at the TCP-connection level.
|
||||
@@ -98,8 +146,16 @@ internal sealed class NovellLdapConnection : ILdapConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
|
||||
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
|
||||
/// <summary>
|
||||
/// Factory that produces fresh <see cref="NovellLdapConnection"/> instances, carrying the TLS
|
||||
/// server-certificate validation policy (a supplied callback, or an <c>allowInsecure</c> bypass) so
|
||||
/// it is wired onto each connection at construction time.
|
||||
/// </summary>
|
||||
internal sealed class NovellLdapConnectionFactory(
|
||||
bool allowInsecure = false,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
: ILdapConnectionFactory
|
||||
{
|
||||
public ILdapConnection Create() => new NovellLdapConnection();
|
||||
public ILdapConnection Create() =>
|
||||
new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,14 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: binds against a live directory via the real
|
||||
/// Novell-backed connection factory.
|
||||
/// Novell-backed connection factory. The TLS server-certificate validation policy
|
||||
/// (<see cref="LdapOptions.ServerCertificateValidationCallback"/> or the
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
|
||||
/// connection is built with it.
|
||||
/// </summary>
|
||||
public LdapAuthService(LdapOptions options)
|
||||
: this(options, new NovellLdapConnectionFactory())
|
||||
: this(options, new NovellLdapConnectionFactory(
|
||||
options.AllowInsecure, options.ServerCertificateValidationCallback))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -92,7 +96,13 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
// Abstractions change could add DirectoryUnavailable to disambiguate.
|
||||
try
|
||||
{
|
||||
conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
|
||||
conn.Connect(
|
||||
_options.Server,
|
||||
_options.Port,
|
||||
_options.Transport,
|
||||
_options.AllowInsecure,
|
||||
_options.ConnectionTimeoutMs,
|
||||
_options.ServerCertificateValidationCallback);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
|
||||
@@ -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, four 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>
|
||||
@@ -27,6 +29,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(
|
||||
|
||||
@@ -87,6 +87,33 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
Assert.Single(recent, e => e.EventType == "create-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
|
||||
{
|
||||
// Auth-005: KeyPrefix is the bare token prefix ("mxgw"), NOT "mxgw_key-1". The key id is
|
||||
// already its own column; embedding it produced a self-referential value that disagreed with
|
||||
// the read/test paths and confused admin tooling.
|
||||
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.NotNull(found);
|
||||
Assert.Equal("mxgw", found!.KeyPrefix);
|
||||
|
||||
// The same bare prefix is surfaced by the admin list projection.
|
||||
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.Equal("mxgw", item.KeyPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
|
||||
{
|
||||
@@ -265,6 +292,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]
|
||||
|
||||
@@ -212,6 +212,51 @@ public class ApiKeyVerifierTests
|
||||
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// --- Auth-002: a failed best-effort MarkUsedAsync must NOT fail a valid key ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds()
|
||||
{
|
||||
// MarkUsedAsync is best-effort "last used" bookkeeping. A transient storage failure
|
||||
// (SQLITE_BUSY, disk full, locked DB) must not turn an otherwise-valid credential into a
|
||||
// failed auth: the decision is already made before the usage write. The verifier's contract
|
||||
// is "the only exception path is cancellation", so a non-cancellation MarkUsedAsync failure
|
||||
// is swallowed and the result is still Succeeded == true.
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash),
|
||||
MarkUsedException = new InvalidOperationException("SQLITE_BUSY"),
|
||||
};
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Null(result.Failure);
|
||||
Assert.NotNull(result.Identity);
|
||||
Assert.Equal(KeyId, result.Identity!.KeyId);
|
||||
Assert.True(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MarkUsedThrowsOperationCanceled_Propagates()
|
||||
{
|
||||
// The ONLY exception path is cancellation: an OperationCanceledException from the usage
|
||||
// write (e.g. the request was cancelled mid-write) is honoured and re-thrown, not swallowed.
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash),
|
||||
MarkUsedException = new OperationCanceledException(),
|
||||
};
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
@@ -253,6 +298,9 @@ public class ApiKeyVerifierTests
|
||||
public string? MarkUsedKeyId { get; private set; }
|
||||
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
|
||||
|
||||
/// <summary>When set, <see cref="MarkUsedAsync"/> throws this exception (after recording the call).</summary>
|
||||
public Exception? MarkUsedException { get; set; }
|
||||
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
FindByKeyIdCalled = true;
|
||||
@@ -267,6 +315,11 @@ public class ApiKeyVerifierTests
|
||||
MarkUsedCalled = true;
|
||||
MarkUsedKeyId = keyId;
|
||||
MarkUsedWhenUtc = whenUtc;
|
||||
if (MarkUsedException is not null)
|
||||
{
|
||||
return Task.FromException(MarkUsedException);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,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 +253,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]
|
||||
|
||||
@@ -164,6 +164,36 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
||||
Assert.Empty(ScopeSerializer.Deserialize(""));
|
||||
}
|
||||
|
||||
// --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{")]
|
||||
[InlineData("{\"a\":1}")] // valid JSON, but an object, not a string[]
|
||||
[InlineData("42")] // valid JSON, but a number
|
||||
[InlineData("[\"read\",")] // truncated/partial write
|
||||
public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value)
|
||||
{
|
||||
// A poisoned scopes column (tampering, partial write, format change, buggy writer) must
|
||||
// degrade to a zero-scope set rather than throwing on the verification hot path.
|
||||
IReadOnlySet<string> scopes = ScopeSerializer.Deserialize(value);
|
||||
Assert.Empty(scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow()
|
||||
{
|
||||
// Insert a row whose scopes column holds malformed (non-array) JSON, then read it through
|
||||
// the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the
|
||||
// verifier relies on for its "only exception path is cancellation" contract).
|
||||
await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json");
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Empty(found!.Scopes);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: "mxgw_ab12",
|
||||
@@ -213,6 +243,33 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task InsertWithRawScopesAsync(string keyId, string scopesJson)
|
||||
{
|
||||
// Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed
|
||||
// value can be persisted to simulate tampering / a partial or buggy write.
|
||||
await using SqliteConnection connection =
|
||||
await _factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", "mxgw");
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3 };
|
||||
command.Parameters.AddWithValue("$display_name", "Corrupt Key");
|
||||
command.Parameters.AddWithValue("$scopes", scopesJson);
|
||||
command.Parameters.AddWithValue("$constraints", DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UnixEpoch.ToString("O"));
|
||||
command.Parameters.AddWithValue("$last_used_utc", DBNull.Value);
|
||||
command.Parameters.AddWithValue("$revoked_utc", DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
@@ -34,6 +34,27 @@ public sealed class SqliteMigratorTests : IDisposable
|
||||
Assert.Equal(1, await CountSchemaVersionRowsAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentVersion_Is2_ToMatchDonorGatewayDeployedSchema() =>
|
||||
// The store was extracted from MxAccessGateway, whose deployed gateway-auth.db is
|
||||
// stamped version 2. The library must stamp 2 (not reset to 1) so it does not refuse
|
||||
// those existing databases on first boot. Locking this invariant.
|
||||
Assert.Equal(2, SqliteAuthSchema.CurrentVersion);
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndStaysAt2()
|
||||
{
|
||||
// The deployed-gateway scenario: a database already provisioned at version 2.
|
||||
var migrator = new SqliteAuthStoreMigrator(Factory);
|
||||
await migrator.MigrateAsync(CancellationToken.None);
|
||||
await SetVersionAsync(2);
|
||||
|
||||
await migrator.MigrateAsync(CancellationToken.None); // must not throw
|
||||
|
||||
Assert.Equal(2, await ReadVersionAsync());
|
||||
Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MigrateAsync_FutureSchemaVersion_Throws()
|
||||
{
|
||||
|
||||
+49
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
@@ -85,4 +86,52 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
Assert.Contains(validators, v => v is LdapOptionsValidator);
|
||||
}
|
||||
|
||||
// --- Auth-001: ValidateOnStart must run options validation at host startup, not first login ---
|
||||
|
||||
private static IConfiguration BuildInsecureConfiguration() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
|
||||
// Plaintext transport without AllowInsecure: the validator must reject this.
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.None),
|
||||
[$"{LdapSection}:AllowInsecure"] = "false",
|
||||
})
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbLdapAuth_StartingHost_FailsForInsecureConfig()
|
||||
{
|
||||
// The misconfiguration must surface at host start, not deferred until the first login
|
||||
// (i.e. the first ILdapAuthService resolution). ValidateOnStart wires the host's
|
||||
// start-time options validation, so StartAsync must throw OptionsValidationException.
|
||||
IConfiguration config = BuildInsecureConfiguration();
|
||||
|
||||
using IHost host = new HostBuilder()
|
||||
.ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
|
||||
.Build();
|
||||
|
||||
OptionsValidationException ex =
|
||||
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
|
||||
|
||||
Assert.Contains(nameof(LdapOptions.Transport), string.Join(" ", ex.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbLdapAuth_StartingHost_SucceedsForSecureConfig()
|
||||
{
|
||||
// A valid (secure) config must start cleanly — proving ValidateOnStart does not reject
|
||||
// well-formed options.
|
||||
IConfiguration config = BuildConfiguration();
|
||||
|
||||
using IHost host = new HostBuilder()
|
||||
.ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
await host.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
@@ -19,6 +20,10 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
// ---- observation -----
|
||||
|
||||
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
|
||||
|
||||
/// <summary>The server-certificate validation callback passed to the most recent <see cref="Connect"/> call.</summary>
|
||||
public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }
|
||||
|
||||
public List<string> BoundDns { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -107,9 +112,16 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
|
||||
// ---- ILdapConnection -----
|
||||
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
public void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
ConnectCertCallback = serverCertificateValidationCallback;
|
||||
if (_throwOnConnect)
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
@@ -80,6 +81,56 @@ public class LdapAuthServiceTests
|
||||
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
|
||||
}
|
||||
|
||||
// --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation
|
||||
// callback is threaded into the connection rather than being silently ignored. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ReceivesAllowInsecureFlag_FromOptions()
|
||||
{
|
||||
// The allowInsecure flag must reach the connection (it used to be an unused parameter).
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(
|
||||
Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.NotNull(fake.ConnectArgs);
|
||||
Assert.True(fake.ConnectArgs!.Value.AllowInsecure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ReceivesConfiguredCertValidationCallback()
|
||||
{
|
||||
// A consumer-supplied RemoteCertificateValidationCallback must be passed through to the
|
||||
// connection so production callers can pin a CA / validate the SAN — the seam no longer
|
||||
// discards it.
|
||||
RemoteCertificateValidationCallback callback = (_, _, _, _) => true;
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(
|
||||
Opts() with { ServerCertificateValidationCallback = callback },
|
||||
new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.Same(callback, fake.ConnectCertCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_NoCertCallbackConfigured_PassesNull()
|
||||
{
|
||||
// Default: no callback configured -> null reaches the connection, which means the
|
||||
// production adapter falls back to OS-trust-store validation (documented behaviour).
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.Null(fake.ConnectCertCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
|
||||
{
|
||||
|
||||
@@ -72,4 +72,20 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea/
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
@@ -0,0 +1,77 @@
|
||||
# ZB.MOM.WW.Configuration
|
||||
|
||||
Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — the package is linked directly into the consuming application at build time. There is no central validation process; all validation runs in-process at startup.
|
||||
|
||||
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. 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`.
|
||||
|
||||
---
|
||||
|
||||
## Package
|
||||
|
||||
| Package | Responsibilities | Key Dependencies |
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Configuration` | `OptionsValidatorBase<TOptions>` (abstract `IValidateOptions` base, failure-accumulating), `ValidationBuilder` (rule primitives: `Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`, `MinCount`, `RequireThat`, `Add`), `ServiceCollectionExtensions.AddValidatedOptions` (bind + validator + `ValidateOnStart` in one call), `ConfigPreflight` (fluent pre-host raw-`IConfiguration` checker). | `Microsoft.Extensions.Options`, `Microsoft.Extensions.Options.ConfigurationExtensions`, `Microsoft.Extensions.Configuration.Abstractions`, `Microsoft.Extensions.DependencyInjection.Abstractions` |
|
||||
|
||||
Single package; no ASP.NET Core framework reference.
|
||||
|
||||
---
|
||||
|
||||
## Build, test, and pack commands
|
||||
|
||||
```bash
|
||||
# From ZB.MOM.WW.Configuration/
|
||||
|
||||
# Build
|
||||
dotnet build ZB.MOM.WW.Configuration.slnx
|
||||
|
||||
# Test (no external dependencies required)
|
||||
dotnet test ZB.MOM.WW.Configuration.slnx
|
||||
|
||||
# Pack (one .nupkg lands in artifacts/)
|
||||
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
Test breakdown:
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Configuration.Tests` | 27 |
|
||||
| **Total** | **27** |
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly with the command above.
|
||||
|
||||
---
|
||||
|
||||
## Source layout
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Configuration/
|
||||
├── Directory.Build.props # version (0.1.0), TFM (net10.0), central package mgmt
|
||||
├── Directory.Packages.props # pinned package versions
|
||||
├── ZB.MOM.WW.Configuration.slnx # solution file
|
||||
├── src/
|
||||
│ └── ZB.MOM.WW.Configuration/ # library project
|
||||
│ ├── OptionsValidatorBase.cs
|
||||
│ ├── ValidationBuilder.cs
|
||||
│ ├── Checks.cs # internal shared rule wording
|
||||
│ ├── ServiceCollectionExtensions.cs
|
||||
│ └── ConfigPreflight.cs
|
||||
└── tests/
|
||||
└── ZB.MOM.WW.Configuration.Tests/ # xUnit test project (27 tests)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
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`
|
||||
|
||||
Design documentation:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/spec/SPEC.md` — normalized validation target
|
||||
- `~/Desktop/scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` — proposed shared-library API
|
||||
- `~/Desktop/scadaproj/components/configuration/current-state/` — per-project current state (code-verified)
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Library -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<!-- Test only -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
|
||||
<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>
|
||||
@@ -0,0 +1,112 @@
|
||||
# ZB.MOM.WW.Configuration
|
||||
|
||||
Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). This is a **library, not a service** — the package is linked directly into the consuming application at build time. It extracts the `IValidateOptions` plumbing the three apps share — failure accumulation, rule primitives, bind+validate DI wiring, and pre-host preflight — so that domain-specific validation rules stay per-project and the boilerplate does not drift.
|
||||
|
||||
---
|
||||
|
||||
## What's in the box
|
||||
|
||||
| Type | Description |
|
||||
|---|---|
|
||||
| `OptionsValidatorBase<TOptions>` | Abstract `IValidateOptions<TOptions>`. Override `protected void Validate(ValidationBuilder v, TOptions o)` to declare failures; the base aggregates all failures and returns a single `ValidateOptionsResult`. |
|
||||
| `ValidationBuilder` | Failure accumulator. Primitives: `Required`, `Port`, `HostPort`, `PositiveTimeSpan`, `OneOf`, `MinCount`, `RequireThat(bool, msg)`, `Add(msg)`. Properties: `Failures` (read), `IsValid`. |
|
||||
| `ServiceCollectionExtensions` | `AddValidatedOptions<TOptions, TValidator>(IConfiguration config, string sectionPath)` — binds the section, registers the validator, and calls `ValidateOnStart()` in a single extension method. Returns `OptionsBuilder<TOptions>`. |
|
||||
| `ConfigPreflight` | Pre-host raw-`IConfiguration` checker. Fluent API: `For(config)`, `.Require(key, predicate, reason)`, `.RequireValue(key)`, `.RequirePort(key)`, `.When(cond, block)`, `.ThrowIfInvalid()`. |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Validator subclass
|
||||
|
||||
```csharp
|
||||
public sealed class ClusterOptionsValidator : OptionsValidatorBase<ClusterOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, ClusterOptions o)
|
||||
{
|
||||
v.MinCount(o.SeedNodes, 2, "Cluster:SeedNodes");
|
||||
v.OneOf(o.Strategy, new[] { "keep-oldest" }, "Cluster:Strategy");
|
||||
v.PositiveTimeSpan(o.StableAfter, "Cluster:StableAfter");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. DI wiring
|
||||
|
||||
```csharp
|
||||
builder.Services.AddValidatedOptions<ClusterOptions, ClusterOptionsValidator>(
|
||||
builder.Configuration, "ScadaBridge:Cluster");
|
||||
```
|
||||
|
||||
This binds `ScadaBridge:Cluster`, registers `ClusterOptionsValidator`, and enables `ValidateOnStart` — the app refuses to start if the section fails validation.
|
||||
|
||||
### 3. Pre-host preflight
|
||||
|
||||
```csharp
|
||||
ConfigPreflight.For(configuration)
|
||||
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
|
||||
.RequirePort("Node:RemotingPort")
|
||||
.When(role == "Site", p => p.RequireValue("Node:SiteId"))
|
||||
.ThrowIfInvalid();
|
||||
```
|
||||
|
||||
Use `ConfigPreflight` before `WebApplication.CreateBuilder` for critical keys (node role, remoting port, site ID) that must be present and valid before the DI container is even constructed.
|
||||
|
||||
---
|
||||
|
||||
## Building and testing
|
||||
|
||||
```bash
|
||||
# from ZB.MOM.WW.Configuration/
|
||||
dotnet test ZB.MOM.WW.Configuration.slnx
|
||||
```
|
||||
|
||||
All tests run with no external dependencies:
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Configuration.Tests` | 27 |
|
||||
| **Total** | **27** |
|
||||
|
||||
---
|
||||
|
||||
## Packing
|
||||
|
||||
```bash
|
||||
dotnet pack ZB.MOM.WW.Configuration.slnx -c Release -o ./artifacts
|
||||
```
|
||||
|
||||
Produces one `.nupkg` file in `artifacts/`:
|
||||
|
||||
```
|
||||
ZB.MOM.WW.Configuration.0.1.0.nupkg
|
||||
```
|
||||
|
||||
`GeneratePackageOnBuild` is off — pack explicitly as above. Version is set in `Directory.Build.props`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
The package has a minimal closure — only `Microsoft.Extensions.*` abstractions:
|
||||
|
||||
- `Microsoft.Extensions.Options`
|
||||
- `Microsoft.Extensions.Options.ConfigurationExtensions`
|
||||
- `Microsoft.Extensions.Configuration.Abstractions`
|
||||
- `Microsoft.Extensions.DependencyInjection.Abstractions`
|
||||
|
||||
No third-party packages; no ASP.NET Core framework reference.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
**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`
|
||||
|
||||
Design documentation lives alongside that backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/spec/SPEC.md` — normalized validation target
|
||||
- `~/Desktop/scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` — proposed API
|
||||
- `~/Desktop/scadaproj/components/configuration/current-state/` — per-project current state (code-verified)
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.Configuration/ZB.MOM.WW.Configuration.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.Configuration.Tests/ZB.MOM.WW.Configuration.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Internal rule primitives shared by <see cref="ValidationBuilder"/> (validates a bound options
|
||||
/// object) and <see cref="ConfigPreflight"/> (validates raw <c>IConfiguration</c>). Each method
|
||||
/// returns <c>null</c> when valid, or a formatted <c>"<field> <reason>"</c> message
|
||||
/// otherwise. Centralizing them keeps wording identical across both front-ends.
|
||||
/// </summary>
|
||||
internal static class Checks
|
||||
{
|
||||
internal static string? Required(string? value, string field) =>
|
||||
string.IsNullOrWhiteSpace(value) ? $"{field} is required" : null;
|
||||
|
||||
internal static string? Port(int value, string field) =>
|
||||
value is < 1 or > 65535 ? $"{field} must be between 1 and 65535 (was {value})" : null;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a raw string as a TCP port (parse + range), returning <c>null</c> when valid.
|
||||
/// Centralizes the port wording for callers that hold the raw config value. Parsing is strict
|
||||
/// and culture-invariant (<see cref="NumberStyles.None"/>): a leading sign or surrounding
|
||||
/// whitespace is rejected. Both the parse-failure and range-failure messages quote the offending
|
||||
/// raw value so they read consistently.
|
||||
/// </summary>
|
||||
internal static string? PortValue(string? raw, string field) =>
|
||||
int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var port) && port is >= 1 and <= 65535
|
||||
? null
|
||||
: $"{field} must be between 1 and 65535 (was '{raw ?? "null"}')";
|
||||
|
||||
/// <summary>
|
||||
/// Validates a non-bracketed <c>host:port</c> endpoint (port 1-65535). Bracketed IPv6
|
||||
/// literals (<c>[::1]:port</c>) are out of scope and are rejected.
|
||||
/// </summary>
|
||||
internal static string? HostPort(string? value, string field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return $"{field} is required";
|
||||
var idx = value.LastIndexOf(':');
|
||||
if (idx <= 0 || idx == value.Length - 1
|
||||
|| value.AsSpan(0, idx).Contains(':')
|
||||
|| !int.TryParse(value[(idx + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|
||||
|| port is < 1 or > 65535)
|
||||
return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string? PositiveTimeSpan(TimeSpan value, string field) =>
|
||||
value <= TimeSpan.Zero ? $"{field} must be a positive duration (was {value})" : null;
|
||||
|
||||
internal static string? OneOf(string? value, IReadOnlyCollection<string> allowed, string field) =>
|
||||
value is not null && allowed.Contains(value, StringComparer.OrdinalIgnoreCase)
|
||||
? null
|
||||
: $"{field} must be one of [{string.Join(", ", allowed)}] (was '{value ?? "null"}')";
|
||||
|
||||
internal static string? MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) =>
|
||||
value is null || value.Count < min
|
||||
? $"{field} must contain at least {min} item(s) (had {value?.Count ?? 0})"
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent aggregator for validating raw <see cref="IConfiguration"/> BEFORE the host/DI container
|
||||
/// exists (e.g. pre-Akka startup). Collects all failures and surfaces them together via
|
||||
/// <see cref="ThrowIfInvalid"/>. For options that flow through DI, prefer
|
||||
/// <see cref="ServiceCollectionExtensions.AddValidatedOptions{TOptions, TValidator}"/>.
|
||||
/// </summary>
|
||||
public sealed class ConfigPreflight
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly List<string> _failures = [];
|
||||
|
||||
private ConfigPreflight(IConfiguration configuration) => _configuration = configuration;
|
||||
|
||||
/// <summary>Starts a preflight over <paramref name="configuration"/>.</summary>
|
||||
public static ConfigPreflight For(IConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
return new ConfigPreflight(configuration);
|
||||
}
|
||||
|
||||
/// <summary>The accumulated failure messages (empty when valid).</summary>
|
||||
public IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
/// <summary>True when no failures have been accumulated.</summary>
|
||||
public bool IsValid => _failures.Count == 0;
|
||||
|
||||
/// <summary>Requires the value at <paramref name="key"/> to satisfy <paramref name="predicate"/>.</summary>
|
||||
public ConfigPreflight Require(string key, Func<string?, bool> predicate, string reason)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(predicate);
|
||||
if (!predicate(_configuration[key])) _failures.Add($"{key} {reason}");
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Requires a non-empty value at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequireValue(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
return AddIf(Checks.Required(_configuration[key], key));
|
||||
}
|
||||
|
||||
/// <summary>Requires a valid integer TCP port (1-65535) at <paramref name="key"/>.</summary>
|
||||
public ConfigPreflight RequirePort(string key)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
return AddIf(Checks.PortValue(_configuration[key], key));
|
||||
}
|
||||
|
||||
/// <summary>Runs <paramref name="block"/> only when <paramref name="condition"/> holds (role-conditional rules).</summary>
|
||||
public ConfigPreflight When(bool condition, Action<ConfigPreflight> block)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(block);
|
||||
if (condition) block(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Throws <see cref="InvalidOperationException"/> listing all failures when invalid; otherwise returns.</summary>
|
||||
public void ThrowIfInvalid()
|
||||
{
|
||||
if (_failures.Count > 0)
|
||||
throw new InvalidOperationException(
|
||||
$"Configuration validation failed:\n{string.Join("\n", _failures.Select(e => $" - {e}"))}");
|
||||
}
|
||||
|
||||
private ConfigPreflight AddIf(string? message)
|
||||
{
|
||||
if (message is not null) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for <see cref="IValidateOptions{TOptions}"/> implementations that removes the
|
||||
/// failure-accumulation plumbing. Override <see cref="Validate(ValidationBuilder, TOptions)"/> and
|
||||
/// use the supplied <see cref="ValidationBuilder"/>; the base aggregates ALL failures and returns
|
||||
/// <see cref="ValidateOptionsResult.Success"/> only when none were recorded.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type being validated.</typeparam>
|
||||
public abstract class OptionsValidatorBase<TOptions> : IValidateOptions<TOptions>
|
||||
where TOptions : class
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, TOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var builder = new ValidationBuilder();
|
||||
Validate(builder, options);
|
||||
return builder.IsValid
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(builder.Failures);
|
||||
}
|
||||
|
||||
/// <summary>Records validation failures for <paramref name="options"/> on <paramref name="builder"/>.</summary>
|
||||
/// <param name="builder">The accumulator to record failures on.</param>
|
||||
/// <param name="options">The options instance to validate.</param>
|
||||
protected abstract void Validate(ValidationBuilder builder, TOptions options);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>DI extensions for binding-and-validating an options section in one call.</summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Binds <typeparamref name="TOptions"/> to the configuration section at
|
||||
/// <paramref name="sectionPath"/>, registers <typeparamref name="TValidator"/> as its
|
||||
/// <see cref="IValidateOptions{TOptions}"/>, and enables <c>ValidateOnStart</c> so a bad
|
||||
/// configuration fails fast at host startup rather than on first use.
|
||||
/// </summary>
|
||||
/// <typeparam name="TOptions">The options type to bind and validate.</typeparam>
|
||||
/// <typeparam name="TValidator">The validator registered for <typeparamref name="TOptions"/>.</typeparam>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configuration">The configuration to bind from.</param>
|
||||
/// <param name="sectionPath">The configuration section path (e.g. <c>"ScadaBridge:Cluster"</c>).</param>
|
||||
/// <returns>The <see cref="OptionsBuilder{TOptions}"/> for further chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <typeparamref name="TValidator"/> is registered as a singleton (it is consumed by the
|
||||
/// singleton options factory). It must therefore be safe to use as a singleton — do not
|
||||
/// inject scoped dependencies into it.
|
||||
/// </remarks>
|
||||
public static OptionsBuilder<TOptions> AddValidatedOptions<TOptions, TValidator>(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionPath)
|
||||
where TOptions : class
|
||||
where TValidator : class, IValidateOptions<TOptions>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>());
|
||||
return services.AddOptions<TOptions>()
|
||||
.Bind(configuration.GetSection(sectionPath))
|
||||
.ValidateOnStart();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Accumulates validation failures for an options object. Passed by
|
||||
/// <see cref="OptionsValidatorBase{TOptions}"/> into your <c>Validate</c> override; each primitive
|
||||
/// both checks a value and appends a consistently-formatted message on failure. Use
|
||||
/// <see cref="RequireThat"/>/<see cref="Add"/> for custom or cross-field rules.
|
||||
/// </summary>
|
||||
public sealed class ValidationBuilder
|
||||
{
|
||||
private readonly List<string> _failures = [];
|
||||
|
||||
/// <summary>The accumulated failure messages (empty when validation passed).</summary>
|
||||
public IReadOnlyList<string> Failures => _failures;
|
||||
|
||||
/// <summary>True when no failures have been accumulated.</summary>
|
||||
public bool IsValid => _failures.Count == 0;
|
||||
|
||||
/// <summary>Records <paramref name="message"/> as a failure when <paramref name="ok"/> is false.</summary>
|
||||
public ValidationBuilder RequireThat(bool ok, string message)
|
||||
{
|
||||
if (!ok) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Unconditionally records <paramref name="message"/> as a failure.</summary>
|
||||
public ValidationBuilder Add(string message)
|
||||
{
|
||||
_failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Requires a non-null, non-whitespace string.</summary>
|
||||
public ValidationBuilder Required(string? value, string field) => AddIf(Checks.Required(value, field));
|
||||
|
||||
/// <summary>Requires a TCP port in 1-65535.</summary>
|
||||
public ValidationBuilder Port(int value, string field) => AddIf(Checks.Port(value, field));
|
||||
|
||||
/// <summary>Requires a 'host:port' endpoint with a valid port.</summary>
|
||||
public ValidationBuilder HostPort(string? value, string field) => AddIf(Checks.HostPort(value, field));
|
||||
|
||||
/// <summary>Requires a strictly positive duration.</summary>
|
||||
public ValidationBuilder PositiveTimeSpan(TimeSpan value, string field) => AddIf(Checks.PositiveTimeSpan(value, field));
|
||||
|
||||
/// <summary>
|
||||
/// Requires the value to be one of <paramref name="allowed"/> (case-insensitive). A
|
||||
/// <c>null</c> value fails this rule; call <see cref="Required"/> first if the field may be
|
||||
/// absent and you want a "required" message instead of a "must be one of" message.
|
||||
/// </summary>
|
||||
public ValidationBuilder OneOf(string? value, IReadOnlyCollection<string> allowed, string field) => AddIf(Checks.OneOf(value, allowed, field));
|
||||
|
||||
/// <summary>Requires a collection with at least <paramref name="min"/> items.</summary>
|
||||
public ValidationBuilder MinCount<T>(IReadOnlyCollection<T>? value, int min, string field) => AddIf(Checks.MinCount(value, min, field));
|
||||
|
||||
private ValidationBuilder AddIf(string? message)
|
||||
{
|
||||
if (message is not null) _failures.Add(message);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Configuration</PackageId>
|
||||
<Authors>ZB.MOM.WW</Authors>
|
||||
<Description>Startup configuration-validation toolkit for the ZB.MOM.WW SCADA family: a failure-accumulating IValidateOptions base, reusable rule primitives (port, host:port, required, positive-duration, one-of, min-count), a bind+validate+ValidateOnStart DI helper, and a pre-host ConfigPreflight aggregator for raw IConfiguration. Extracts the validation plumbing the apps share; domain rules stay per-project.</Description>
|
||||
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class AddValidatedOptionsTests
|
||||
{
|
||||
private sealed class NodeOptions { public int Port { get; set; } public string? Name { get; set; } }
|
||||
|
||||
private sealed class NodeValidator : OptionsValidatorBase<NodeOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, NodeOptions o)
|
||||
{
|
||||
v.Port(o.Port, "Node:Port");
|
||||
v.Required(o.Name, "Node:Name");
|
||||
}
|
||||
}
|
||||
|
||||
private static IHost BuildHost(Dictionary<string, string?> config)
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(config);
|
||||
builder.Services.AddValidatedOptions<NodeOptions, NodeValidator>(builder.Configuration, "Node");
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bad_config_throws_at_startup()
|
||||
{
|
||||
using var host = BuildHost(new() { ["Node:Port"] = "0", ["Node:Name"] = "" });
|
||||
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Good_config_starts_and_binds()
|
||||
{
|
||||
using var host = BuildHost(new() { ["Node:Port"] = "8080", ["Node:Name"] = "central" });
|
||||
await host.StartAsync();
|
||||
var opts = host.Services.GetRequiredService<IOptions<NodeOptions>>().Value;
|
||||
Assert.Equal(8080, opts.Port);
|
||||
Assert.Equal("central", opts.Name);
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calling_twice_registers_validator_once()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Node:Port"] = "0", ["Node:Name"] = "" })
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
|
||||
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var validators = provider.GetServices<IValidateOptions<NodeOptions>>().ToArray();
|
||||
Assert.Single(validators);
|
||||
|
||||
// Resolving the options surfaces each accumulated failure exactly once, not doubled.
|
||||
var ex = Assert.Throws<OptionsValidationException>(
|
||||
() => provider.GetRequiredService<IOptions<NodeOptions>>().Value);
|
||||
Assert.Equal(2, ex.Failures.Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the exact failure-message wording produced by the shared <c>Checks</c> seam through its
|
||||
/// public front-ends (<see cref="ConfigPreflight"/> for raw port values, <see cref="ValidationBuilder"/>
|
||||
/// for host:port endpoints). Covers Configuration-002 (consistent quoting) and Configuration-003
|
||||
/// (strict, culture-invariant port parsing).
|
||||
/// </summary>
|
||||
public sealed class ChecksWordingTests
|
||||
{
|
||||
private static IConfiguration Config(string key, string? value) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { [key] = value })
|
||||
.Build();
|
||||
|
||||
private static string PortFailure(string? rawValue)
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", rawValue)).RequirePort("X:Port");
|
||||
return Assert.Single(pf.Failures);
|
||||
}
|
||||
|
||||
// Configuration-002: range failure and parse failure must quote the offending value the same way.
|
||||
|
||||
[Fact]
|
||||
public void PortValue_range_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was '0')", PortFailure("0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_high_range_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was '70000')", PortFailure("70000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_parse_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was 'notaport')", PortFailure("notaport"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_null_failure_renders_null()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was 'null')", PortFailure(null));
|
||||
}
|
||||
|
||||
// Configuration-003: strict, culture-invariant parsing rejects sign and surrounding whitespace.
|
||||
|
||||
[Theory]
|
||||
[InlineData("+5000")]
|
||||
[InlineData(" 5000")]
|
||||
[InlineData("5000 ")]
|
||||
[InlineData(" 5000 ")]
|
||||
[InlineData("-1")]
|
||||
public void PortValue_rejects_loose_inputs(string raw)
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", raw)).RequirePort("X:Port");
|
||||
Assert.False(pf.IsValid);
|
||||
Assert.Equal($"X:Port must be between 1 and 65535 (was '{raw}')", Assert.Single(pf.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_accepts_plain_in_range_port()
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", "5000")).RequirePort("X:Port");
|
||||
Assert.True(pf.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host:+5000")]
|
||||
[InlineData("host: 5000")]
|
||||
[InlineData("host:5000 ")]
|
||||
public void HostPort_rejects_loose_port_inputs(string value)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort(value, "X:Endpoint");
|
||||
Assert.False(b.IsValid);
|
||||
Assert.Equal($"X:Endpoint must be 'host:port' with port 1-65535 (was '{value}')", Assert.Single(b.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostPort_accepts_plain_endpoint()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort("host:5000", "X:Endpoint");
|
||||
Assert.True(b.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class ConfigPreflightTests
|
||||
{
|
||||
private static IConfiguration Config(Dictionary<string, string?> values) =>
|
||||
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
|
||||
[Fact]
|
||||
public void Aggregates_all_failures()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Bogus", ["Node:RemotingPort"] = "0" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.Require("Node:Role", v => v is "Central" or "Site", "must be 'Central' or 'Site'")
|
||||
.RequirePort("Node:RemotingPort");
|
||||
Assert.False(pf.IsValid);
|
||||
Assert.Equal(2, pf.Failures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_runs_block_only_if_condition_true()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Site" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.When(cfg["Node:Role"] == "Site",
|
||||
p => p.RequireValue("Node:SiteId"));
|
||||
Assert.False(pf.IsValid); // SiteId missing
|
||||
Assert.Contains(pf.Failures, f => f.Contains("Node:SiteId"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void When_false_does_not_run_block()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Role"] = "Central" });
|
||||
var pf = ConfigPreflight.For(cfg)
|
||||
.When(cfg["Node:Role"] == "Site", p => p.RequireValue("Node:SiteId"));
|
||||
Assert.True(pf.IsValid); // block skipped, no failure recorded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_throws_aggregated_message()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "" });
|
||||
var ex = Assert.Throws<InvalidOperationException>(() =>
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid());
|
||||
Assert.StartsWith("Configuration validation failed:", ex.Message);
|
||||
Assert.Contains(" - Node:Name", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThrowIfInvalid_is_noop_when_valid()
|
||||
{
|
||||
var cfg = Config(new() { ["Node:Name"] = "ok" });
|
||||
ConfigPreflight.For(cfg).RequireValue("Node:Name").ThrowIfInvalid(); // does not throw
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class OptionsValidatorBaseTests
|
||||
{
|
||||
private sealed class SampleOptions
|
||||
{
|
||||
public int Port { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SampleValidator : OptionsValidatorBase<SampleOptions>
|
||||
{
|
||||
protected override void Validate(ValidationBuilder v, SampleOptions o)
|
||||
{
|
||||
v.Port(o.Port, "Sample:Port");
|
||||
v.Required(o.Name, "Sample:Name");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_when_clean()
|
||||
{
|
||||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 8080, Name = "ok" });
|
||||
Assert.True(r.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fails_and_reports_all_failures()
|
||||
{
|
||||
var r = new SampleValidator().Validate(null, new SampleOptions { Port = 0, Name = "" });
|
||||
Assert.True(r.Failed);
|
||||
Assert.Equal(2, r.Failures!.Count());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
public sealed class ValidationBuilderTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
[InlineData(65535, true)]
|
||||
[InlineData(65536, false)]
|
||||
public void Port_validates_range(int port, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Port(port, "X:Port");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(" ", false)]
|
||||
[InlineData("ok", true)]
|
||||
public void Required_rejects_null_empty_whitespace(string? value, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Required(value, "X:Name");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host:5000", true)]
|
||||
[InlineData("host", false)]
|
||||
[InlineData("host:0", false)]
|
||||
[InlineData("host:notaport", false)]
|
||||
[InlineData("::1", false)]
|
||||
public void HostPort_validates_endpoint(string value, bool valid)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort(value, "X:Endpoint");
|
||||
Assert.Equal(valid, b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveTimeSpan_rejects_zero_and_negative()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.PositiveTimeSpan(TimeSpan.Zero, "X:T1").PositiveTimeSpan(TimeSpan.FromSeconds(-1), "X:T2");
|
||||
Assert.Equal(2, b.Failures.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneOf_is_case_insensitive()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.OneOf("CENTRAL", new[] { "Central", "Site" }, "X:Role");
|
||||
Assert.True(b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OneOf_null_value_fails()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.OneOf(null, new[] { "Central", "Site" }, "X:Role");
|
||||
Assert.False(b.IsValid);
|
||||
Assert.Contains(b.Failures, f => f.Contains("X:Role"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinCount_requires_minimum()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.MinCount(new[] { "a" }, 2, "X:Seeds");
|
||||
Assert.False(b.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Accumulates_all_failures_and_RequireThat_Add_work()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.Required(null, "A").RequireThat(false, "B failed").Add("C failed");
|
||||
Assert.Equal(3, b.Failures.Count);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<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.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.Configuration\ZB.MOM.WW.Configuration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -7,6 +7,11 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.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 /
|
||||
non-packed members do not break the build; the src public surface is fully documented. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -103,27 +103,41 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
var cluster = Cluster.Get(system);
|
||||
var self = cluster.SelfMember;
|
||||
var selfUp = self.Status == MemberStatus.Up;
|
||||
|
||||
MemberStatus selfStatus;
|
||||
bool selfUp;
|
||||
bool hasRole;
|
||||
bool isLeader;
|
||||
if (_role is null)
|
||||
try
|
||||
{
|
||||
hasRole = false;
|
||||
var leader = cluster.State.Leader;
|
||||
isLeader = leader is not null && leader == self.Address;
|
||||
// Reading cluster membership can throw while the ActorSystem exists but the cluster has
|
||||
// not finished initialising (e.g. Akka.Cluster not yet configured →
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
hasRole = self.HasRole(_role);
|
||||
var roleLeader = cluster.State.RoleLeader(_role);
|
||||
isLeader = roleLeader is not null && roleLeader == self.Address;
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role);
|
||||
var description = DescribeResult(health, self.Status, selfUp, isLeader);
|
||||
var description = DescribeResult(health, selfStatus, selfUp, isLeader);
|
||||
var result = health switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace ZB.MOM.WW.Health.Akka;
|
||||
/// <summary>
|
||||
/// Health check that maps the local node's Akka cluster membership status to a
|
||||
/// <see cref="HealthStatus"/> through a configurable <see cref="AkkaClusterStatusPolicy"/>.
|
||||
/// Register to the <see cref="ZbHealthTags.Ready"/> tag (recommended <c>[ready, active]</c>).
|
||||
/// Register to the <see cref="ZbHealthTags.Ready"/> tag only — cluster membership is a readiness
|
||||
/// concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for the leader / active-node probe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider. If it is not yet
|
||||
@@ -42,7 +43,21 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
var status = Cluster.Get(system).SelfMember.Status;
|
||||
MemberStatus status;
|
||||
try
|
||||
{
|
||||
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
||||
// has not finished initialising (e.g. Akka.Cluster not yet configured →
|
||||
// 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;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var health = _policy.Evaluate(status);
|
||||
var description = $"Akka cluster member status: {status}";
|
||||
var result = health switch
|
||||
|
||||
@@ -13,14 +13,15 @@ namespace ZB.MOM.WW.Health;
|
||||
/// The probe is injectable via <see cref="GrpcDependencyOptions.Probe"/>; the default drives the
|
||||
/// channel to a connected state with <see cref="GrpcChannel.ConnectAsync"/>. The result is
|
||||
/// <see cref="HealthStatus.Healthy"/> when the probe returns <c>true</c>, and
|
||||
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws an
|
||||
/// <see cref="RpcException"/>, or times out / is cancelled within
|
||||
/// <see cref="GrpcDependencyOptions.Timeout"/>.
|
||||
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws any exception
|
||||
/// (<see cref="RpcException"/> or otherwise), or times out within
|
||||
/// <see cref="GrpcDependencyOptions.Timeout"/>. External cancellation of the supplied
|
||||
/// <see cref="CancellationToken"/> propagates as an <see cref="OperationCanceledException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Recommended registration tags: <see cref="ZbHealthTags.Ready"/> and
|
||||
/// <see cref="ZbHealthTags.Active"/> — a missing downstream gRPC dependency makes the node both
|
||||
/// not-ready and not-able-to-act. The registrant applies the tags.
|
||||
/// Recommended registration tag: <see cref="ZbHealthTags.Ready"/> only — downstream gRPC
|
||||
/// reachability is a readiness concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for
|
||||
/// the leader / active-node probe. The registrant applies the tag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcDependencyHealthCheck : IHealthCheck
|
||||
@@ -74,6 +75,15 @@ public sealed class GrpcDependencyHealthCheck : IHealthCheck
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{name} probe timed out after {_options.Timeout}.", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Catch-all to match the sibling DatabaseHealthCheck: any other probe error
|
||||
// (e.g. InvalidOperationException / HttpRequestException / SocketException from the
|
||||
// transport, or anything a custom probe throws) maps to Unhealthy rather than escaping
|
||||
// the IHealthCheck boundary. The OCE/Rpc external-cancellation handlers above run first,
|
||||
// so caller cancellation still propagates.
|
||||
return HealthCheckResult.Unhealthy($"{name} probe failed: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -28,9 +28,9 @@ public static class ZbHealthEndpointExtensions
|
||||
/// emits a minimal <c>200 OK</c> body.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// The <see cref="IEndpointConventionBuilder"/> for the readiness (<c>/health/ready</c>) endpoint.
|
||||
/// A single tier is returned (rather than a composite) to keep the API simple; conventions
|
||||
/// applied to the result affect only the readiness endpoint.
|
||||
/// A composite <see cref="IEndpointConventionBuilder"/> that fans every chained convention out to
|
||||
/// <em>all three</em> health endpoints (readiness, active, and liveness). For example,
|
||||
/// <c>endpoints.MapZbHealth().RequireHost("…")</c> gates all three endpoints, as a caller expects.
|
||||
/// </returns>
|
||||
public static IEndpointConventionBuilder MapZbHealth(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
@@ -47,7 +47,7 @@ public static class ZbHealthEndpointExtensions
|
||||
ResponseWriter = responseWriter,
|
||||
}).AllowAnonymous();
|
||||
|
||||
endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
|
||||
var active = endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = static c => c.Tags.Contains(ZbHealthTags.Active),
|
||||
ResponseWriter = responseWriter,
|
||||
@@ -56,12 +56,38 @@ public static class ZbHealthEndpointExtensions
|
||||
// Liveness: run no checks. The endpoint returns 200 as long as the process can respond.
|
||||
// No JSON writer — the empty report would carry no useful data, so the framework default
|
||||
// (a minimal plain-text body) is sufficient.
|
||||
endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
|
||||
var live = endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = static _ => false,
|
||||
}).AllowAnonymous();
|
||||
|
||||
return ready;
|
||||
return new CompositeEndpointConventionBuilder(ready, active, live);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IEndpointConventionBuilder"/> that forwards each convention to several
|
||||
/// underlying builders, so conventions chained onto the result of
|
||||
/// <see cref="MapZbHealth(IEndpointRouteBuilder, ZbHealthEndpointOptions?)"/> apply to all three
|
||||
/// health endpoints rather than just one.
|
||||
/// </summary>
|
||||
private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder
|
||||
{
|
||||
private readonly IEndpointConventionBuilder[] _builders;
|
||||
|
||||
public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) =>
|
||||
_builders = builders;
|
||||
|
||||
public void Add(Action<EndpointBuilder> convention)
|
||||
{
|
||||
foreach (var builder in _builders)
|
||||
builder.Add(convention);
|
||||
}
|
||||
|
||||
public void Finally(Action<EndpointBuilder> finalConvention)
|
||||
{
|
||||
foreach (var builder in _builders)
|
||||
builder.Finally(finalConvention);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,7 +96,10 @@ public static class ZbHealthEndpointExtensions
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder to map onto.</param>
|
||||
/// <param name="configure">Callback that mutates a fresh <see cref="ZbHealthEndpointOptions"/>.</param>
|
||||
/// <returns>The <see cref="IEndpointConventionBuilder"/> for the readiness endpoint.</returns>
|
||||
/// <returns>
|
||||
/// A composite <see cref="IEndpointConventionBuilder"/> that fans chained conventions out to all
|
||||
/// three health endpoints.
|
||||
/// </returns>
|
||||
public static IEndpointConventionBuilder MapZbHealth(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
Action<ZbHealthEndpointOptions> configure)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user