Compare commits

..

185 Commits

Author SHA1 Message Date
Joseph Doherty 73f6931730 docs(index): HistorianGateway SendEvent amortized via the event-session pool 2026-06-25 12:37:44 -04:00
Joseph Doherty cc57c857b8 test: cover GalaxyBrowseProjector, GalaxyDeployNotifier, GalaxyHierarchyRefreshService (ported from mxaccessgw on adoption) 2026-06-25 12:28:34 -04:00
Joseph Doherty 6143b7e4f3 docs(index): HistorianGateway browse/metadata now amortized through the session pool 2026-06-25 11:09:43 -04:00
Joseph Doherty ac71caca84 chore: bump ZB.MOM.WW.GalaxyRepository to 0.2.0 2026-06-25 10:55:57 -04:00
Joseph Doherty be993d4d54 test: cover WatchDeployEvents scoped-count re-projection 2026-06-25 10:54:51 -04:00
Joseph Doherty 480f7c7a49 feat: scope GalaxyRepositoryGrpcService results via IGalaxyBrowseScopeProvider 2026-06-25 10:48:34 -04:00
Joseph Doherty 2e4df81ba9 feat: add IGalaxyBrowseScopeProvider (default no-op) + registration 2026-06-25 10:44:22 -04:00
Joseph Doherty 94218c936a feat: add IGalaxyRepository.GetAlarmAttributesAsync + AlarmAttributesSql 2026-06-25 10:38:12 -04:00
Joseph Doherty da09be3127 feat: add GalaxyAlarmAttributeRow + MapAlarmRow
Ports the alarm-attribute row type and its internal mapping helper from
mxaccessgw (ZB.MOM.WW.MxGateway.Server.Galaxy) into the shared lib as
the first step of the galaxy-0.2.0-mxaccessgw-gaps feature branch.
Adds InternalsVisibleTo so the test project can exercise MapAlarmRow
without a database. Five unit tests all pass; zero-warning build.
2026-06-25 10:33:44 -04:00
Joseph Doherty 624cc5a408 docs(index): HistorianGateway handshake amortization (A1) done + live-validated
Leased-session pool (Historian:SessionPool) reuses pre-authenticated sessions
across reads/writes/status (~4.7x), keepalive + reactive re-auth, PooledHistorianClient
facade (services unchanged), HistorianSession upstream re-vendored @ be60d0b.

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-25 04:28:58 -04:00
Joseph Doherty 38cf17917a docs(historiangw): test count 741->744/723 (final-review polish tests)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-24 19:52:02 -04:00
Joseph Doherty 71cec3dcff docs(historiangw): production-readiness warnings + refreshed test count (719->741)
Non-Development boots now also warn (warn-only) on relative runtime-artifact
paths + secret hygiene (pending.md D2/D3); TLS warn predicate broadened to
non-Development (Production + Staging).

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-24 19:43:45 -04:00
Joseph Doherty 99c153ac23 docs(historiangw): refresh test count (719 total / 698 green on macOS) 2026-06-24 18:30:27 -04:00
Joseph Doherty 5f743d05d6 docs(historiangw): single TLS port (Http1AndHttp2) deployment posture
Dev two plaintext endpoints (appsettings.Development.json) unchanged;
production now uses a single Kestrel Https Http1AndHttp2 endpoint (ALPN)
multiplexing dashboard + gRPC over one port; warn-only without TLS (valid
behind reverse proxy / Kubernetes ingress).

Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
2026-06-24 16:20:23 -04:00
Joseph Doherty b80abbb14b docs(index): HistorianGateway store-forward now FasterLog-backed; refresh test count (702/681) 2026-06-24 11:19:56 -04:00
Joseph Doherty 6c2d16d4af docs: refresh HistorianGateway + GalaxyRepository status in index
HistorianGateway is now pushed to gitea (gitea.dohertylan.com/dohertj2/historiangw), and
ZB.MOM.WW.GalaxyRepository is published to the Gitea feed and consumed as a PackageReference
(no longer a cross-repo ProjectReference). Updates the sister-project row, the component
table, and the GalaxyRepository narrative; test figure 584 green -> 590 total (584 on macOS).
2026-06-24 07:28:25 -04:00
Joseph Doherty a08ddab9dd chore: retire unused ZB.MOM.WW.SPHistorianClient (stale partial port; superseded by histsdk vendored in HistorianGateway; no consumers, not on feed) 2026-06-24 06:45:10 -04:00
Joseph Doherty 744eb090ac docs(scadaproj): index ZB.MOM.WW.HistorianGateway sidecar + GalaxyRepository shared lib
- Add HistorianGateway to the Runtime/implementation table (single-process
  .NET 10 x64 gRPC sidecar; no COM/x86; 584 tests; local only, not yet
  pushed to gitea)
- Update "What this repository is" count (five → six pieces of source;
  add GalaxyRepository)
- Add HistorianGateway paragraph to Cross-project relationships / Net effect
  (independent sidecar; no runtime coupling to the other three; depends on
  shared GalaxyRepository lib via ProjectReference)
- Add ZB.MOM.WW.GalaxyRepository row to Component normalization table +
  full description paragraph (built 0.1.0; consumed by HistorianGateway;
  mxaccessgw adoption is a follow-on; not yet published to Gitea feed)
- Add HistorianGateway primary commands block (build/test/run/live-integration)
- Extend Shared GLAuth note to cover HistorianGateway
2026-06-24 00:41:29 -04:00
Joseph Doherty 94512acf1f fix(galaxyrepo): drop no-op ValidateOnStart (consumer owns validation) 2026-06-23 20:36:28 -04:00
Joseph Doherty 2c6c764d3c test(galaxyrepo): projector + cache tests; dispose semaphores; pack 0.1.0 2026-06-23 20:34:32 -04:00
Joseph Doherty a30f8551e9 feat(galaxyrepo): reusable gRPC service + AddZbGalaxyRepository DI 2026-06-23 20:26:59 -04:00
Joseph Doherty afd0287f54 feat(galaxyrepo): hierarchy cache + snapshot + refresh service + projector 2026-06-23 20:22:35 -04:00
Joseph Doherty 1041f87b59 feat(galaxyrepo): SQL browse provider (hierarchy + attributes) 2026-06-23 20:12:33 -04:00
Joseph Doherty 5572edda85 feat(galaxyrepo): canonical galaxy_repository.v1 proto (neutral namespace) 2026-06-23 20:05:39 -04:00
Joseph Doherty aff7264df8 feat(galaxyrepo): scaffold ZB.MOM.WW.GalaxyRepository shared lib 2026-06-23 19:48:43 -04:00
Joseph Doherty 510b0010d6 docs(historian-gateway): implementation plan + task ledger (31 tasks) 2026-06-23 19:43:08 -04:00
Joseph Doherty 42ad31aded docs(historian-gateway): brainstormed design for ZB.MOM.WW.HistorianGateway sidecar 2026-06-23 19:31:54 -04:00
Joseph Doherty e3c0503a4f docs(sphistorianclient): mark RemoteGrpc (2023 R2) live-verified 2026-06-19 06:57:06 -04:00
Joseph Doherty a0527f9b5a fix(sphistorianclient): gRPC auth handshake uses StorageService.ValidateClientCredential
The RemoteGrpc orchestrator drove the SSPI/NTLM token loop through
HistoryService.ExchangeKey, which the 2023 R2 contract analysis shows is a
separate key-exchange/cert op — not the credential handshake. The server
rejected the NTLM Type-1 token at round 0. The Negotiate loop belongs on
StorageService.ValidateClientCredential (Handle/InBuff -> Status/OutBuff;
field names match the 2020 native contract). Live-verified end-to-end against
a 2023 R2 Historian (wonder-sql-vd03): SysTimeSec raw read returns correct
timestamped values.
2026-06-19 06:56:44 -04:00
Joseph Doherty 5f7d7e1b58 docs(sphistorianclient): document HISTORIAN_PORT env var; mark plan tasks complete 2026-06-19 06:09:43 -04:00
Joseph Doherty 78418346df build(sphistorianclient): pack 0.1.0 nupkg 2026-06-19 06:02:05 -04:00
Joseph Doherty 4920b89666 docs(sphistorianclient): correct retrieval-mode count (15) + EnsureTag verification scope 2026-06-19 06:01:07 -04:00
Joseph Doherty 989db9317d docs(sphistorianclient): add CLAUDE.md + README.md 2026-06-19 05:58:13 -04:00
Joseph Doherty 81bf7322f0 feat(sphistorianclient): add AddZbSpHistorianClient DI extension 2026-06-19 05:53:56 -04:00
Joseph Doherty 8033a7f12d fix(sphistorianclient): resolve port build/test fallout 2026-06-19 05:49:22 -04:00
Joseph Doherty 63cddfb65b feat(sphistorianclient): port SDK source + tests, rebrand namespace to ZB.MOM.WW.SPHistorianClient 2026-06-19 05:45:06 -04:00
Joseph Doherty 965f5006f2 feat(sphistorianclient): scaffold shared library skeleton (props, csprojs, slnx) 2026-06-19 05:40:10 -04:00
Joseph Doherty 294da8b2db docs(sphistorianclient): implementation plan + task tracking 2026-06-19 05:36:24 -04:00
Joseph Doherty bbb7942788 docs(sphistorianclient): approved design for ZB.MOM.WW.SPHistorianClient port 2026-06-19 05:29:51 -04:00
Joseph Doherty d5b134b117 docs: add MES + Delmia-DNC integration API/MXAccess specs
mes-delmia-integration-api.md: endpoints, request/response DTOs, and the MXAccess flag handshake for MESAPI (in-repo MesNotifier) and DelmiaIntegration (DNC Downloader.asmx -> WWNotifier /notify -> Galaxy $DelmiaReceiver). mesrec.md / nj.md: live Galaxy receiver + reactor attribute references.
2026-06-17 06:52:36 -04:00
Joseph Doherty eb8b44c29d loader: purge legacy driver in overlay namespace on teardown (self-heal nw-uns-modbus placeholder) 2026-06-08 07:07:22 -04:00
Joseph Doherty a6fa36043a loader: equipment is driver-less (drop Modbus placeholder, NULL DriverInstanceId) 2026-06-08 06:42:31 -04:00
Joseph Doherty 05a4a547f4 feat(loader): canonical EQ-+uuid EquipmentIds (passes OtOpcUa full DraftValidator); clean by UnsLine scope 2026-06-07 11:18:39 -04:00
Joseph Doherty 4d57e34ff3 docs(loader): record live-values verification + 396/1036 explanation for company overlay 2026-06-07 06:08:36 -04:00
Joseph Doherty b3d8990a0f fix(loader): keep empty folderPath distinct in vtag ids; dedupe verify args; readme wait-seconds 2026-06-07 05:07:00 -04:00
Joseph Doherty 5655b75fe6 feat(loader): company overlay as VirtualTags mirroring the galaxy mirror + verify --require-good 2026-06-07 04:59:51 -04:00
Joseph Doherty dce6f83488 loader: add populate-equipment (company-shape Equipment overlay) + scope verify-equipment
populate-equipment loads the Northwind Enterprise/Site/Area/Line/Equipment/Signal
shape from company-uns.json as a second Equipment-kind namespace (nw-uns) alongside
the galaxy mirror — 3 areas / 8 lines / 40 equipment / 1036 signals. Friendly
DisplayName, stable logical-Id NodeId. verify-equipment now scopes to the nw-area-*
overlay by default (--all for the whole tree). Verified live on :4840 against OtOpcUa
master's Equipment-namespace materialization (structure-only; leaves are
BadWaitingForInitialData). clean now drops the overlay too.
2026-06-06 16:19:53 -04:00
Joseph Doherty fd34e25cb1 feat(uns-loader): verify-equipment — recursive Equipment UNS tree browse + leaf count
browse_summary assumes the flat 2-level Galaxy hierarchy; the Equipment tree is deep
(Area/Line/Equipment/[FolderPath]/Signal). Add browse_tree (recursive leaf descent) + a
verify-equipment subcommand that reports/asserts the leaf signal count (--expect N), for
verifying OtOpcUa equipment-namespace structure materialisation. Smoke-tested against a live
:4840 (40 folders / 396 leaf signals).
2026-06-06 15:25:17 -04:00
Joseph Doherty eb26bf3248 Add Galaxy UNS artifacts + reloadable OtOpcUa loader tool
galaxy-hierarchy.json: full AVEVA Galaxy DEV hierarchy pulled live via the
MxGateway .NET client (129 objects, 14k attrs). company-uns.json/.tree.txt +
gen_uns.py: a fake-company (Northwind) ISA-95 UNS modeled on OtOpcUa's
Cluster->Namespace->Area->Line->Equipment->Tag schema, grounded in the 40
TestMachine instances. otopcua-uns-loader/: reloadable generate/populate/verify/
clean tool that recreates + verifies the galaxy mirror (396 live tags across 40
machines) in OtOpcUa's config DB after a rebuild.
2026-06-06 14:22:25 -04:00
Joseph Doherty e5a609be83 docs(theme): mark themeissues #6 resolved in 0.3.1
Interactive-render nav fix (CSS display:none-when-closed + nav-state.js
MutationObserver re-wire) shipped in 0.3.1 and verified — ScadaBridge Central UI
NavCollapseTests now pass. All six issues now resolved (5 fixed, 1 tradeoff).
2026-06-05 08:32:03 -04:00
Joseph Doherty f1efe6e081 fix(theme): 0.3.1 — interactive-render nav backstop (issue #6)
Under an interactive Blazor render mode the runtime replaces the prerendered
<details> after DOMContentLoaded, so nav-state.js (wired on load, re-run only on
'enhancedload') never wires the live rail — no aria sync, no persistence, no
active-reveal — and native <details> content-hiding is unreliable, leaving a
collapsed section's items visible. 0.3.1:
- nav-state.js: add a MutationObserver backstop that re-runs apply() when
  details.rail-section nodes are (re)inserted; idempotent via the per-element
  init guard, loop-safe (childList-only + active-reveal's !open guard).
- layout.css: explicit .rail-section:not([open]) > .rail-section-body{display:none}
  so visual collapse works across all render modes.
- themeissues.md: document issue #6; Directory.Build.props 0.3.0 -> 0.3.1.
48 bUnit tests green.
2026-06-05 07:18:30 -04:00
Joseph Doherty 0e41e7c2e4 fix(theme): resolve nav/login kit issues + bump 0.2.1 -> 0.3.0
Addresses ZB.MOM.WW.Theme/themeissues.md:
- #1 NavRailSection <summary> renders aria-expanded (SSR from Expanded),
  kept in sync by nav-state.js on restore + toggle.
- #2 nav-state.js auto-expands the section holding a.rail-link.active
  (transient via data-zbnav-transient — does not overwrite saved state).
- #3 nav-state.js re-applies on Blazor 'enhancedload' (idempotent via
  per-element init guard).
- #5 LoginCard wraps product in span.login-product + optional Heading
  override param.
- #4 documented as an accepted client-only-persistence tradeoff (no code change).

+4 bUnit tests (48 total, all green).
2026-06-05 04:42:24 -04:00
Joseph Doherty 5f97c9d1ed docs(glauth): point all dev/test LDAP at the shared GLAuth on 10.100.0.35
deployment.md / CLAUDE.md / env_vars.md: the per-app LDAP (scadabridge-ldap
container, OtOpcUa DevStubMode, per-box C:\publish\glauth) is replaced by one
shared zb-shared-glauth on 10.100.0.35:3893 (dc=zb,dc=local); source of truth
infra/glauth/. Fixed stale baseDNs (dc=lmxopcua/dc=otopcua -> dc=zb).
2026-06-04 16:37:52 -04:00
Joseph Doherty 9d373efbe0 docs(glauth): mark shared-GLAuth design implemented + all plan tasks complete 2026-06-04 16:21:13 -04:00
Joseph Doherty 4c0f1eaaf7 fix(glauth): rename OPC/Gw testers to avoid username/group case-collision
glauth exposes each group as cn=<Group> under ou=users, so a case-insensitive
(cn=x) search matched both the user and the group (2 entries -> the shared
ZB.MOM.WW.Auth.Ldap 'exactly one entry' rule failed the bind). Renamed the 4
colliding testers (readonly/writetune/alarmack/gwreader) + the 2 siblings for
consistency: opc-readonly/opc-writeop/opc-writetune/opc-writeconfig/opc-alarmack
and gw-viewer. Verified gw-viewer logs into the MxGateway dashboard as Viewer.
multi-role/admin/designer/etc. were never affected (no case-collision).
2026-06-04 16:19:33 -04:00
Joseph Doherty 0f2b2b8351 feat(glauth): merged shared dev GLAuth directory + compose + runbook (10.100.0.35)
Phase 0 of the shared-GLAuth standardization. config.toml = merged dc=zb,dc=local
directory (15 groups in partitioned 55xx/56xx/57xx families, 14 users incl.
multi-role spanning all groups, serviceaccount search account). compose runs one
glauth/glauth:latest on :3893. README is the deploy/verify runbook. Code-reviewed;
fixed scp -r idempotency in the deploy command (README + plan Task 4).
2026-06-04 15:45:41 -04:00
Joseph Doherty 5be0cec601 docs(glauth): implementation plan + tasks for shared GLAuth standardization
19 tasks across 5 phases: author scadaproj/infra/glauth/ (merged config + compose +
runbook) → deploy/verify on 10.100.0.35 (hard gate, access-prerequisite) → repoint
ScadaBridge (Mac), un-stub OtOpcUa docker-dev, repoint windev MxGateway + OtOpcUa →
retire old glauths → full cross-app verification. Co-located .tasks.json.
2026-06-04 15:37:06 -04:00
Joseph Doherty 106fb8b149 docs(glauth): shared GLAuth standardization design (dev/test consolidation onto 10.100.0.35)
Approved design: consolidate OtOpcUa, MxAccessGateway, ScadaBridge dev/test auth
onto one shared GLAuth at 10.100.0.35:3893 (dc=zb,dc=local, plaintext). App-neutral
source of truth in scadaproj/infra/glauth/; merged directory with gid families
partitioned 55xx/56xx/57xx + multi-role/admin/serviceaccount; per-app Server
repoints; incremental rollout keeping old glauths until verified.
2026-06-04 15:26:32 -04:00
Joseph Doherty b0fe7b15ca fix(theme): render app-shell on desktop Chromium via ::details-content (0.2.1)
Chromium >=121 wraps a <details>'s content in a generated ::details-content
box with content-visibility:hidden while closed. The SSR app-shell ships
closed (no JS) and hides its summary toggle at lg+, so on desktop the rail+page
were invisible and the flex-lg-row layout collapsed to a vertical stack.

Add '.app-shell::details-content { display: contents }' inside the lg+ media
query: dissolving the wrapper box reveals the content regardless of open state
and restores rail/page as direct flex children of .app-shell. Browsers without
::details-content support drop the invalid selector and fall back to the legacy
force-show. Mobile (<lg) and nested NavRailSection disclosures unaffected.

Bump 0.2.0 -> 0.2.1.
2026-06-04 10:23:05 -04:00
Joseph Doherty 3070169e5d docs(ui-theme): record post-adoption site.css prune + reconfirm 0.2.0 on feed
Audit follow-up: the deferred 'dead .sidebar/.nav-link residual' was broader than
logged (OtOpcUa's site.css duplicated and overrode the whole kit shell). Pruned
across all 3 apps on chore/theme-css-prune branches (-167/-95/-106 lines, builds
clean). Note the remaining deferred items (kit layout.css calc review; ScadaBridge
Host transitive kit ref) and reconfirm the Theme 0.2.0 publish is genuine.
2026-06-03 04:38:24 -04:00
Joseph Doherty ea4116cc5b docs(ui-theme): mark merged to local default + pushed to origin (in sync) 2026-06-03 04:15:20 -04:00
Joseph Doherty ca21615090 docs(ui-theme): record 0.2.0 publish + adoption across all 3 apps (local feat branches) 2026-06-03 04:06:20 -04:00
Joseph Doherty a474eb6bd6 chore(theme): bump 0.1.0 -> 0.2.0 (nav persistence + ThemeScripts) 2026-06-03 02:59:27 -04:00
Joseph Doherty 9e4dedc987 fix(theme): guard nav-state.js against duplicate toggle listeners 2026-06-03 02:58:34 -04:00
Joseph Doherty 6aa2ee8095 fix(theme): null/whitespace-safe NavRailSection slug + edge tests 2026-06-03 02:57:07 -04:00
Joseph Doherty e2749b7d69 feat(theme): ThemeScripts + localStorage nav-state enhancer 2026-06-03 02:55:35 -04:00
Joseph Doherty edd49765d6 feat(theme): NavRailSection data-nav-key for persistence 2026-06-03 02:53:15 -04:00
Joseph Doherty 7e11f9aac8 docs(ui-theme): implementation plan + task graph (26 tasks, Phases 0-4) 2026-06-03 02:50:31 -04:00
Joseph Doherty e6e9dbfedb docs(ui-theme): approved adoption design (publish 0.2.0 + full canonical cutover across 3 apps) 2026-06-03 02:35:00 -04:00
Joseph Doherty 6d262f7d7c docs: Auth+Audit normalization PUSHED to origin (gitea) 2026-06-03 — default branches in sync; feat/* kept locally 2026-06-03 00:36:55 -04:00
Joseph Doherty 4b90ebb588 docs: reflect final delivery — Auth+Audit normalization merged to each repo's LOCAL default (main/master) 2026-06-03, NOT pushed (origin untouched), feat/* branches kept 2026-06-03 00:31:07 -04:00
Joseph Doherty 4de61d29f5 docs: PROGRAM COMPLETE — Auth+Audit normalization adopted across all 3 repos (Phases 0-3); mark exit-gate (CLAUDE.md Auth/Audit rows + components/{auth,audit}/GAPS.md adopted, local-only/not-pushed); tasks #10/#30/#31 done 2026-06-02 15:42:23 -04:00
Joseph Doherty 1ec057a32a plan: Task 2.5 (ScadaBridge audit full re-arch C1-C7) DONE+reviewed -> PHASE 2 COMPLETE (audit adopted across all 3 repos, deep/canonical, local-only). Next = Phase 3 Actor->principal wiring 2026-06-02 15:10:54 -04:00
Joseph Doherty a591a9fb47 plan(2.5): ScadaBridge audit C5 done+reviewed (central migration, MSSQL-verified); C6 subsumed (consumer surfaces already canonical via C3 shims); C7 (perf re-baseline + cleanup) in progress 2026-06-02 14:24:32 -04:00
Joseph Doherty e9100d0b74 plan(2.5): ScadaBridge audit C4 done+reviewed (site sidecar); C5 (central migration) in progress 2026-06-02 13:34:12 -04:00
Joseph Doherty 672ac5ff04 plan(2.5): ScadaBridge audit C3 done+reviewed (record swap keystone); C4 (site sidecar) in progress 2026-06-02 13:07:32 -04:00
Joseph Doherty f073241f52 plan(2.5): ScadaBridge audit re-arch C1+C2 done (reviewed); C3 (atomic record swap) in progress 2026-06-02 11:54:57 -04:00
Joseph Doherty 98e957903f plan(2.5): ScadaBridge audit full-rearch design + C1-C7 decomposition (sidecar forwarding, new-table-copy central migration, persisted computed cols, canonical record everywhere) 2026-06-02 10:36:00 -04:00
Joseph Doherty ca2a9ac507 plan(phase2): OtOpcUa 2.1/2.2 + MxGateway 2.3 DONE (deep audit adoption, spec+code reviewed, local-only); ScadaBridge 2.5 pending variant decision 2026-06-02 10:26:55 -04:00
Joseph Doherty abe06a2163 plan(phase2): Task 2.0 gate DONE — verified plan specs materially off (MxGw store moved to lib, OtOpcUa path dormant, SB rename structurally impossible); user chose DEEP adopt + pause; corrected deep design in -phase2-deep.md; PAUSED for review 2026-06-02 09:13:09 -04:00
Joseph Doherty 95681ac0b2 plan(phase1): Tasks 1.5/1.6/1.7 done+reviewed — PHASE 1 COMPLETE across all 3 repos (claims/cookies, dev base DN dc=zb, canonical-six roles + SB SoD collapse + config-DB migrations); next = Phase 2 audit 2026-06-02 08:15:46 -04:00
Joseph Doherty d73762bf76 plan(phase1): ScadaBridge re-arch C5 done+reviewed; Task 1.3 (ApiKeys adopt) COMPLETE across all 3 repos; installer/secret catch noted 2026-06-02 05:51:10 -04:00
Joseph Doherty 02a84b074a plan(phase1): ScadaBridge re-arch C4 done+reviewed (TransportExport excludes keys); C5 (retire entity) next 2026-06-02 05:17:09 -04:00
Joseph Doherty 9b5535ea47 plan(phase1): ScadaBridge re-arch C3 done+reviewed (CentralUI onto seam); C4 next 2026-06-02 04:50:09 -04:00
Joseph Doherty 406ede19dd plan(phase1): ScadaBridge re-arch C2 done+reviewed (mgmt+CLI onto seam); C3 next 2026-06-02 04:25:02 -04:00
Joseph Doherty ba7b38a654 plan(phase1): ScadaBridge re-arch C1 done+reviewed; 2 pre-existing Host.Tests baseline reds fixed; C2 next 2026-06-02 04:03:31 -04:00
Joseph Doherty e69e9c635b plan(phase1): ScadaBridge re-arch discovered architecture (CentralUI direct-repo + TransportExport) + C1-C5 decomposition + transport=exclude-keys 2026-06-02 03:22:19 -04:00
Joseph Doherty a4f9968917 plan(phase1): Auth lib 0.1.3 published (SetScopes/SetEnabled); ScadaBridge re-arch C mapping 2026-06-02 03:14:29 -04:00
Joseph Doherty 290e85cb38 test(auth.apikeys): store-level arg guards + SetEnabledAsync idempotence (review M1/M2) 2026-06-02 03:12:24 -04:00
Joseph Doherty 468959ca8a feat(auth.apikeys): add IApiKeyAdminStore.SetScopesAsync + SetEnabledAsync (editable scopes + reversible enable, no schema change); bump 0.1.3 2026-06-02 03:08:19 -04:00
Joseph Doherty 30c60f9d5f plan(phase1): SB ApiKeys A+B foundation done+reviewed; C/D/E pending 2026-06-02 02:50:57 -04:00
Joseph Doherty d30cdea487 plan(phase1): ScadaBridge ApiKeys full-adopt re-arch spec + sub-task decomposition 2026-06-02 02:29:03 -04:00
Joseph Doherty f2b73367d5 plan(phase1): MxGateway 1.3 done+approved (lib 0.1.2); ScadaBridge 1.3 pending 2026-06-02 02:14:45 -04:00
Joseph Doherty da669bfc9b fix(auth.apikeys): stamp schema version 2 to match donor gateway DBs; bump 0.1.2
The store was extracted from MxAccessGateway, whose deployed gateway-auth.db
is at schema_version=2. The library capped at 1 and threw on a newer on-disk
version -> gateway would fail to boot. Final schema is byte-identical since v1;
stamp 2 so existing deployed DBs interoperate (no key re-issuance). +2 tests.
2026-06-02 01:45:57 -04:00
Joseph Doherty 2d50d5dcf0 plan(phase1): 1.2/1.4 done across 3 repos (lib 0.1.1); remaining 1.3/1.5-1.7 2026-06-02 01:38:50 -04:00
Joseph Doherty aecc106657 fix(auth.ldap): skip LdapOptionsValidator when Enabled=false; bump 0.1.1
A disabled LDAP provider's connection fields are inert — don't require
Server/SearchBase/ServiceAccountDn at startup when Enabled=false. Surfaced
by the MxGateway 1.2 review (dashboard LDAP can be disabled). +1 test.
2026-06-02 01:17:53 -04:00
Joseph Doherty 0586e64f64 plan(phase1): record Task 1.2 review findings + LdapOptionsValidator 0.1.1 question 2026-06-02 01:12:20 -04:00
Joseph Doherty 37c03e5fc2 plan(phase1): note Roles sub-namespace; Task 1.1 done+approved (3 repos) 2026-06-02 00:34:13 -04:00
Joseph Doherty bea08f9673 plan(phase1): lock resolved decisions (SB ApiKeys full adopt, roles, dev hatches) 2026-06-02 00:25:53 -04:00
Joseph Doherty 32fd953969 plan(phase1): Task 1.0 exploration findings + elaborated Auth cutover
Per-app cutover steps mapped to the library surface; flags 5 findings that
change the plan (OtOpcUa section is Security:Ldap not Authentication:Ldap;
singleton 'bug' already mitigated; ScadaBridge inbound API keys are a
re-architecture not a reformat; OtOpcUa config+DB mapping + DevStubMode +
2nd LDAP consumer; MxGateway ApiKeys is the low-risk donor path).
2026-06-02 00:24:03 -04:00
Joseph Doherty c715565bd2 build(audit): add Gitea push.sh mirroring Auth's 2026-06-02 00:13:24 -04:00
Joseph Doherty f98fa84e4a plan: implementation plan + task graph for Auth+Audit normalization
Phase 0 command-exact (publish + feed-map); Phases 1-3 decomposed into
bite-sized cutover tasks with files-to-edit contracts, classification,
parallelizability, and per-phase explore/elaborate gates. Co-located
.tasks.json mirrors native tasks #7-#31.
2026-06-02 00:11:48 -04:00
Joseph Doherty 6ec1ea7d65 docs: design for full Auth+Audit normalization across 3 sister projects
Approved brainstorming output: two-library program (publish + adopt
ZB.MOM.WW.Auth then ZB.MOM.WW.Audit across OtOpcUa, MxAccessGateway,
ScadaBridge), library-major waterfall, ending with audit Actor wired
from the Auth principal. Local-only delivery; verified feed/source state.
2026-06-02 00:04:33 -04:00
Joseph Doherty c3ab37523a docs: record ZB.MOM.WW.Configuration fleet-wide adoption + add design/plan
Configuration is now adopted across all three sister apps (local branches),
so flip the status lines in CLAUDE.md, components/configuration/GAPS.md, and the
lib README/CLAUDE.md from 'not adopted' to adopted (also corrects 27->42 tests).
Adds the brainstorm design doc + bite-sized implementation plan (+tasks.json)
under docs/plans/ that drove the adoption.
2026-06-01 23:18:02 -04:00
Joseph Doherty 2f124fa02c docs(observability): record telemetry follow-ons DONE (metric normalization, ScadaBridge instruments, OTLP opt-in, site metrics listener, Serilog alignment) 2026-06-01 17:16:46 -04:00
Joseph Doherty 6c2a43a238 docs: plan for ZB.MOM.WW.Telemetry follow-ons (A additive/hygiene, B metric normalization, C ScadaBridge instruments, D OTLP opt-in) 2026-06-01 16:32:57 -04:00
Joseph Doherty dee55aadc6 docs(observability): record ZB.MOM.WW.Telemetry adoption across 3 apps; correct false MxGateway logging-status claim
All 3 apps adopted on branch feat/adopt-zb-telemetry (behaviour-preserving).
Records the per-repo result + accepted scope deviations (ScadaBridge keeps
LoggerConfigurationFactory + TraceContextEnricher instead of AddZbSerilog;
MxGateway keeps GatewayLogScope, exposes redaction via ILogRedactor seam) and
deferred follow-ons (#6 ms->s, #7 meter rename, #9 app instruments, OTLP, and
the new ScadaBridge Site-node HTTP/1.1 metrics-listener item). Corrects the
prior false 'MxGateway logging adopted on its own branch' claim — that migration
actually landed in this pass.
2026-06-01 15:58:10 -04:00
Joseph Doherty 30425726d4 docs: implementation plan for ZB.MOM.WW.Telemetry adoption across the 3 sister apps
13 tasks: Task 0 publishes/verifies the 2 nupkgs on Gitea (gates all); then 3
independent per-repo phases — OtOpcUa (1-3), ScadaBridge (4-6), MxGateway (7-11,
incl. the high-risk MEL->Serilog swap) — and Task 12 scadaproj bookkeeping last.
Records two behaviour-preserving refinements vs the design: ScadaBridge keeps
LoggerConfigurationFactory (+TraceContextEnricher) instead of AddZbSerilog, and
MxGateway keeps GatewayLogScope as-is. Breaking items #6/#7 deferred.
2026-06-01 15:24:28 -04:00
Joseph Doherty 3729ff2152 docs: design for ZB.MOM.WW.Telemetry adoption across the 3 sister apps
Second cross-fleet shared-library adoption (after Health). Full scope:
AddZbTelemetry (OTel Resource identity triple + standard instrumentation +
Prometheus /metrics) on all 3, plus shared Serilog on all 3 — including the
MxGateway MEL->Serilog migration. Records the correction that MxGateway's
logging was NOT actually adopted on main despite the docs' claim. Behaviour-
preserving bar; breaking items (#6 unit, #7 rename) deferred.
2026-06-01 15:11:50 -04:00
Joseph Doherty 19f7ea5eeb docs(health): record ZB.MOM.WW.Health adoption across 3 apps + deferrals + accepted /health/active startup behaviour change 2026-06-01 13:50:09 -04:00
Joseph Doherty 1e91784ba3 docs(health-plan): publish done; fix source-mapping (two patterns); note user-level creds 2026-06-01 13:23:46 -04:00
Joseph Doherty 5a965639f9 docs: implementation plan for ZB.MOM.WW.Health adoption across the 3 sister apps
Detailed task-by-task plan (publish to Gitea, then per-repo behaviour-preserving
probe swaps) incorporating recon findings that revised the design: MxGateway worker
IPC is named pipes (custom SQLite readiness probe instead of gRPC), ScadaBridge
ActorSystem is not in DI (transient bridge), downstream gRPC probes + IDbContextFactory
switch + ScadaBridge seam unification deferred.
2026-06-01 13:15:48 -04:00
Joseph Doherty f72403d6f0 docs: design for ZB.MOM.WW.Health adoption across the 3 sister apps
Plan to integrate the built-but-unadopted Health library into OtOpcUa,
MxAccessGateway, and ScadaBridge: Gitea-registry distribution, per-repo
behaviour-preserving probe swaps (preset-based), canonical tiers + writer,
MxGateway-first sequencing.
2026-06-01 13:01:36 -04:00
Joseph Doherty f47d4e1030 docs: remove upcoming.md (remaining normalization candidates won't be standardized) 2026-06-01 12:42:51 -04:00
Joseph Doherty 7ae25f8510 Re-stamp Telemetry-002/003 resolutions: nested redaction implemented in 05cc62a
Telemetry-002 was first resolved by documenting the scalar-only limitation; it is now
implemented (recursive nested redaction). Updated the two resolution notes to record
05cc62a and the replaced limitation test, preserving the audit trail. README unchanged
(still 0 pending / 35 total).
2026-06-01 12:13:05 -04:00
Joseph Doherty 05cc62aab3 Implement nested log redaction (Telemetry-002)
RedactionEnricher now projects each property into a mutable view the ILogRedactor
can edit: scalars stay as their CLR value, while StructureValue/SequenceValue/
DictionaryValue become nested IDictionary<string,object?>/IList<object?> the
redactor descends into recursively. A field nested inside a destructured {@Object}
can now be masked or removed — closing the gap documented as a limitation.

- Project/Rebuild round-trip preserves StructureValue.TypeTag and original
  dictionary keys; redactor-synthesised plain dicts/lists are rebuilt too.
- Untouched properties are not reallocated: structural ValueEquals skips write-back
  unless a property actually changed. Scalar fast path and no-redactor/no-property
  short-circuits retained.
- +5 nested-reach tests (mask/remove a field, sequence element, dictionary value,
  two-levels-deep); the old 'cannot reach' limitation test replaced. Serilog 34, 0 warnings.
- ILogRedactor XML doc + library README updated to document the recursive reach.
2026-06-01 12:12:26 -04:00
Joseph Doherty ae0ccc9a3a Mark all baseline code-review findings resolved
All 35 findings fixed in 544a6dd and marked Status: Resolved with resolution
notes. README regenerated: 0 pending / 35 total across 6 libraries.
2026-06-01 11:22:37 -04:00
Joseph Doherty 544a6ddb77 Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
2026-06-01 11:22:14 -04:00
Joseph Doherty 26ba1c7215 Baseline code review of the six ZB.MOM.WW.* shared libraries
All six libraries reviewed at commit 5f75cd4 against their components/ specs,
following code-reviews/REVIEW-PROCESS.md. 35 findings (0 Critical, 1 High,
9 Medium, 25 Low); none block adoption.

- Auth      0/0/3/3  (security core sound; startup-validation + key-verify contract gaps)
- Telemetry 0/1/2/5  (HIGH Telemetry-001: redactor 'remove' is a no-op -> secrets reach sinks)
- Health    0/0/2/4  (Akka checks throw instead of Degraded when cluster not yet up)
- Theme     0/0/1/5  (undocumented Bootstrap-collapse JS dep; token/CSS hygiene)
- Audit     0/0/1/4  (composite re-throws OCE vs never-throw writer contract)
- Configuration 0/0/0/4 (DI idempotency, port-parse strictness, packaging)

Cross-cutting: XML docs authored but GenerateDocumentationFile unset -> docs
not shipped in any nupkg (Auth/Health/Telemetry/Configuration/Audit).

README.md regenerated from the per-library findings; regen-readme.py --check passes.
2026-06-01 11:08:12 -04:00
Joseph Doherty 5f75cd4dab Add per-library code-review scaffolding for the ZB.MOM.WW.* shared libs
Adapts the code-reviews convention (process, README generator, template) from
the ScadaBridge app model (per-src/-module, Akka conventions) to scadaproj's
reality: six shared libraries reviewed against their components/ specs.

- REVIEW-PROCESS.md: review unit is a library; library->component-spec mapping;
  checklist re-targeted for reusable .NET libs (public API/semver, packaging &
  dependency hygiene, spec/shared-contract adherence) instead of actor/supervision.
- _template/findings.md: library/packages/component-spec/shared-contract header.
- regen-readme.py: per-library prose, data-driven Summary, '-' for unreviewed.
- Seed Auth/Theme/Health/Telemetry/Configuration/Audit findings stubs (0 findings).
- README.md generated; --check passes.
2026-06-01 10:46:16 -04:00
Joseph Doherty 899efc2cbf Merge fix/config-otopcua-draft-accuracy: correct OtOpcUa draft-validation description
DraftValidator is dormant (no src/ caller); enforcement is DB-side sp_ValidateDraft.
2026-06-01 10:13:34 -04:00
Joseph Doherty fbf0f23e76 docs(config): correct OtOpcUa draft-validation description
The C# DraftValidator/DraftSnapshot has NO live caller in OtOpcUa src/ (verified
repo-wide) — it is dormant complement code. The enforced pre-publish draft
validation runs DB-side in the sp_ValidateDraft stored procedure (Status='Draft'
-> sp_PublishGeneration lifecycle). Reframe across current-state/SPEC/GAPS/README/
CLAUDE.md from 'runtime draft validation' + a false publish-pipeline caller to
'dormant managed validator; enforcement is DB-side'. Out-of-scope conclusion
for ZB.MOM.WW.Configuration is unchanged.
2026-06-01 10:13:29 -04:00
Joseph Doherty e47ecacb0d Merge feat/zb-mom-ww-configuration: Configuration normalization component + ZB.MOM.WW.Configuration (0.1.0)
Shared startup-options-validation library (single package, 27 tests) — OptionsValidatorBase,
ValidationBuilder primitives, AddValidatedOptions (ValidateOnStart), and pre-host ConfigPreflight
(byte-compatible with ScadaBridge's StartupValidator). Plus components/configuration normalization
docs (spec, shared-contract, 3x current-state, GAPS) and index registration. Not yet adopted by
the three apps — adoption tracked in components/configuration/GAPS.md.
2026-06-01 09:58:08 -04:00
Joseph Doherty 69fb6cb077 chore: mark configuration plan tasks complete 2026-06-01 09:56:01 -04:00
Joseph Doherty a29f226a70 docs: list Checks.cs in library CLAUDE.md src tree 2026-06-01 09:55:47 -04:00
Joseph Doherty 3fa77b70fc docs: register ZB.MOM.WW.Configuration in indexes 2026-06-01 09:51:22 -04:00
Joseph Doherty 46c4bfae31 docs(config): components/configuration normalization (spec, shared-contract, current-state x3, GAPS, README) 2026-06-01 09:48:49 -04:00
Joseph Doherty b754873a44 docs: README + CLAUDE.md; verify 0.1.0 pack
ZB.MOM.WW.Configuration — README with purpose, what's-in-the-box,
three usage snippets (validator subclass, DI wiring, ConfigPreflight),
build/test/pack instructions, and dependency note.
CLAUDE.md with one-screen orientation: package table, commands,
source layout, and component-normalization status note.
27 tests pass; dotnet pack produces exactly one nupkg (0.1.0).
2026-06-01 09:40:20 -04:00
Joseph Doherty 8d91a3021d fix(config): centralize port wording, harden HostPort/key guards, doc null/singleton semantics, add tests 2026-06-01 09:37:53 -04:00
Joseph Doherty 8145d79dc6 feat: ConfigPreflight raw-config aggregator 2026-06-01 09:32:44 -04:00
Joseph Doherty e191893738 feat: AddValidatedOptions bind+validate+ValidateOnStart 2026-06-01 09:31:14 -04:00
Joseph Doherty 563cf44c60 feat: OptionsValidatorBase<TOptions> 2026-06-01 09:29:46 -04:00
Joseph Doherty d18c121033 feat: Checks primitives + ValidationBuilder 2026-06-01 09:28:19 -04:00
Joseph Doherty a104372eac chore: scaffold ZB.MOM.WW.Configuration solution 2026-06-01 09:25:26 -04:00
Joseph Doherty 80e4d59209 plan(config): correct git layout — library committed to outer repo, no nested .git
The sibling libs (Auth/Theme/Health/Telemetry) are tracked as regular files in
the outer scadaproj repo, not separate git repos. Remove the git-init/nested-repo
instructions; all commits target the outer repo on feat/zb-mom-ww-configuration.
2026-06-01 09:23:08 -04:00
Joseph Doherty 229b82efbc plan(config): ZB.MOM.WW.Configuration implementation plan (9 tasks, TDD)
Folds the approved design into the sibling combined-doc format and adds the
phased, bite-sized TDD implementation plan: normalization docs (T1-2), library
scaffold + 4 public types via TDD (T3-7), pack + register (T8-9). Co-located
.tasks.json for executing-plans resume.
2026-06-01 09:18:23 -04:00
Joseph Doherty 18e4b70572 docs(plans): design ZB.MOM.WW.Configuration shared startup-options-validation library
Approved brainstorming design for the Config + validation normalization pass
(Tier-2 candidate in upcoming.md). Scope: startup options validation only,
single package ZB.MOM.WW.Configuration, Approach A (lightweight base + rule
primitives + DI/startup helpers). Full pass = components/configuration/ docs +
built library.
2026-06-01 09:10:35 -04:00
Joseph Doherty a09cc02d46 Merge feat/zb-mom-ww-audit: Audit normalization component + ZB.MOM.WW.Audit (0.1.0)
# Conflicts:
#	CLAUDE.md
#	components/README.md
2026-06-01 09:09:44 -04:00
Joseph Doherty 88c557dee8 fix(telemetry): identical resource across all 3 signals (symmetric OTLP trigger + deterministic service.instance.id)
Fix 1 — symmetric OTLP trigger: ZbSerilogConfig.ApplyOpenTelemetryExport now activates only
when options.Exporter == ZbExporter.Otlp, matching the core OTel metrics/traces path. The
previous fallback that also triggered on a bare OtlpEndpoint is removed; OtlpEndpoint is the
address to use when Otlp is selected, not an independent enable.

Fix 2 — deterministic service.instance.id: ZbResource.InstanceId (MachineName:ProcessId) is
a new public property that produces a stable, process-unique id without a random GUID.
ZbResource.Configure passes autoGenerateServiceInstanceId:false + serviceInstanceId:InstanceId
so metrics and traces never get a random auto-generated id. ZbSerilogConfig.BuildResourceAttributes
adds service.instance.id from ZbResource.InstanceId so the Serilog OTLP log sink carries the
exact same value — all three signals now share an identical resource for cross-signal joins.

Tests: +2 in ZbResourceTests (InstanceId determinism, no-GUID check), +2 in RedactionTests
(service.instance.id parity assertion in BuildResourceAttributes, symmetric OTLP trigger tests).
Total: 9 + 14 = 23 tests, all green.
2026-06-01 08:26:09 -04:00
Joseph Doherty 8311912f40 feat(telemetry): pack ZB.MOM.WW.Telemetry 0.1.0 + README/CLAUDE + register observability component in indexes
- NuGet metadata: expanded Description and PackageTags on both library csproj files
  (opentelemetry;observability;metrics;tracing;prometheus;otlp;... / serilog;logging;...)
- Full dotnet test: 7 (Telemetry) + 12 (Serilog) = 19 tests, all green
- dotnet pack: ZB.MOM.WW.Telemetry.0.1.0.nupkg + ZB.MOM.WW.Telemetry.Serilog.0.1.0.nupkg
  (artifacts/ gitignored, not committed)
- ZB.MOM.WW.Telemetry/README.md: overview, 2 packages, unifying hinge prose,
  exporter options, OTel signals + trace-log correlation, test/pack commands, status
- ZB.MOM.WW.Telemetry/CLAUDE.md: package responsibilities, consumer matrix,
  build/test/pack commands, status + pointers to components/observability/
- components/README.md: Observability row added to component registry table
- CLAUDE.md: Telemetry row added to component-normalization table; intro count
  updated to four shared libs; observability prose paragraph added (MxGateway
  logging adoption noted)
- upcoming.md: Observability item ticked done, pointing at components/observability/
  and ZB.MOM.WW.Telemetry; MxGateway MEL->Serilog adoption noted
- components/observability/README.md: status updated to Built @ 0.1.0, library
  build/pack commands added, MxGateway adoption row updated
2026-06-01 08:20:05 -04:00
Joseph Doherty f569d537d1 fix(telemetry.serilog): don't set process-global Log.Logger in AddZbSerilog (multi-host safe)
Remove the Stage-1 bootstrap-logger line (Log.Logger = new LoggerConfiguration()
.WriteTo.Console().CreateBootstrapLogger()) from AddZbSerilog. A shared library must
not mutate process-global state: when multiple hosts are built in one process (integration
tests, Aspire multi-host, parallel test runs) the second call throws "The logger is
already frozen".

AddSerilog is now called with preserveStaticLogger: true so Serilog.Extensions.Hosting
leaves the static Log.Logger entirely untouched. The DI-registered application logger is
the only artifact AddZbSerilog produces.

Apps that want a pre-Build() bootstrap logger should set Log.Logger themselves in
Program.cs before calling AddZbSerilog — that decision belongs to the application.

Three new regression tests in MultiHostTests verify: two hosts build in the same process
without throwing; Log.Logger is not mutated; each host gets its own independent DI ILogger.

Docs (SPEC.md §5 and shared-contract ZB.MOM.WW.Telemetry.md) updated: the "two-stage
bootstrap" framing is replaced with the correct description — library registers only the
DI application logger; optional Stage-1 bootstrap is the app's responsibility.
2026-06-01 08:13:35 -04:00
Joseph Doherty f1240c0bd4 refactor(telemetry.serilog): review fixes (thread-safe redactor, bootstrap logger, minlevel ordering, test coverage) 2026-06-01 07:48:57 -04:00
Joseph Doherty 37fb84f477 feat(telemetry): core review fixes (Prometheus+OTLP coexistence, ServiceName validation, null guards) + contract overload note
- Fix #1: Prometheus exporter always wired for metrics; OTLP is additive overlay
  when Exporter == ZbExporter.Otlp so /metrics + MapZbMetrics work in all modes.
- Fix #2: BuildOptions throws ArgumentException when ServiceName is null/whitespace.
- Fix #3: AddZbTelemetry(IHostApplicationBuilder) guard: ThrowIfNull(configure)
  added alongside existing ThrowIfNull(builder).
- Fix #6: Contract doc adds IServiceCollection convenience overload signature.
- Tests: +3 new tests (OtlpExporter still serves /metrics, empty ServiceName throws,
  whitespace ServiceName throws). Total: 7 passed (was 4).
2026-06-01 07:43:47 -04:00
Joseph Doherty c284e4d68d docs(audit): register component in indexes + GAPS cross-check 2026-06-01 07:41:45 -04:00
Joseph Doherty 2b856074d5 feat(telemetry.serilog): ILogRedactor seam + OTel log export 2026-06-01 07:40:58 -04:00
Joseph Doherty 70f91a855a feat(telemetry.serilog): TraceContextEnricher for trace<->log correlation 2026-06-01 07:38:54 -04:00
Joseph Doherty 1344f249d0 feat(telemetry.serilog): AddZbSerilog bootstrap + identity enrichers 2026-06-01 07:38:07 -04:00
Joseph Doherty 7f05107c1d feat(audit): AddZbAudit DI extension with safe defaults
TryAdd registers NullAuditRedactor + NoOpAuditWriter so consumer
registrations win; symmetric override tests for both writer and redactor.
2026-06-01 07:34:48 -04:00
Joseph Doherty 3e4d4369bf feat(telemetry): MapZbMetrics Prometheus scrape endpoint 2026-06-01 07:34:26 -04:00
Joseph Doherty 4126e1df54 feat(telemetry): AddZbTelemetry metrics+traces bootstrap 2026-06-01 07:33:51 -04:00
Joseph Doherty 215a646e35 docs(observability): fix metric-convention instrument names + NodeHostname-auto + resolve settled questions
C1: NodeHostname is AUTO throughout. Shared-contract AddZbSerilog doc comment now reads
"SiteId + NodeRole from ZbTelemetryOptions; NodeHostname from Environment.MachineName (auto)".
SPEC.md §0 and §5 prose updated to match. ScadaBridge adoption snippet no longer sets
o.NodeHostname (removed; NodeHostname is auto, not caller-supplied).

C2: METRIC-CONVENTIONS §6.1 OtOpcUa instrument table replaced with code-verified set:
counters otopcua.deploy.applied / driver.lifecycle / virtualtag.eval / scriptedalarm.transition /
opcua.sink.write / redundancy.service_level_change; histogram otopcua.deploy.apply.duration (s);
ActivitySource ZB.MOM.WW.OtOpcUa with spans otopcua.deploy.apply + otopcua.opcua.address_space_rebuild.
Removed invented names (deploy.failed, tag.subscriptions, tag.reads, tag.writes, session.active,
connection.gateway).

C3: METRIC-CONVENTIONS §6.2 MxGateway instrument table replaced with code-verified names from
GatewayMetrics.cs: 13 counters (sessions.opened/closed, commands.started/succeeded/failed,
events.received, queues.overflows, faults, workers.killed/exited, heartbeats.failed,
grpc.streams.disconnected, retries.attempted); 3 histograms ms (workers.startup.duration,
commands.duration, events.stream_send.duration); 4 gauges (sessions.open, workers.running,
events.worker_queue.depth, events.grpc_stream_queue.depth). Removed invented names.

m3: §2 example table replaced mxgateway.session.active + mxgateway.worker.call.duration
(invented) with mxgateway.sessions.open + mxgateway.commands.duration (real). Also fixed
the §2 rule-2 body text example which referenced mxgateway.worker.call.duration.

I4: §5 standard instrumentation table corrected — OtOpcUa now shows  not added for all
five baseline instrumentations, matching current-state/otopcua. All three projects lack
standard instrumentation today; AddZbTelemetry adds it on adoption.

I1+m1: GAPS.md "Decisions still open" — removed the two settled questions (Prometheus-default
and ms→s/meter-rename bundling). Moved them to a new "Decisions settled" section with explicit
resolution notes. One genuinely open question remains (SiteId/NodeRole config binding path).

I2: SPEC.md §5 AddZbSerilog: added note that AddZbSerilog reads Serilog:MinimumLevel from
IConfiguration; callers with a different config key (e.g. ScadaBridge:Logging:MinimumLevel)
apply that override themselves — stays per-project. Shared-contract doc comment updated to match.

I3: MxAccessGateway adoption plan Meters = ["MxGateway.Server"] annotated as temporary with
note to update to ZB.MOM.WW.MxGateway when Gap N1 (Meter-rename) is closed.

m2: SPEC.md §1 now notes AddZbTelemetry also has an IServiceCollection overload for non-standard
hosts, with the IHostApplicationBuilder overload as the primary path.
2026-06-01 07:32:58 -04:00
Joseph Doherty 453ec7358d feat(audit): redactor + writer helpers (Null/Truncating/NoOp/Composite/Redacting)
Code-review fixes: CompositeAuditWriter re-throws OperationCanceledException
(honors cancellation) + evt null-guard; RedactingAuditWriter evt null-guard;
added marker-longer-than-max and cancellation-propagation regression tests.
2026-06-01 07:31:28 -04:00
Joseph Doherty 645388b1f1 feat(telemetry): options + shared OTel Resource 2026-06-01 07:30:54 -04:00
Joseph Doherty a1c3d5ec81 chore: scaffold ZB.MOM.WW.Telemetry solution and projects
Two library projects (ZB.MOM.WW.Telemetry core + Serilog) and two xUnit
test projects; central PM via Directory.Packages.props; dotnet build green.
2026-06-01 07:27:30 -04:00
Joseph Doherty 3934e528f2 feat(audit): AuditEvent record + AuditOutcome + writer/redactor seams
Includes equality-as-normalized-instant remarks on OccurredAtUtc and a
same-instant/different-offset equality regression test (code-review follow-up).
2026-06-01 07:25:31 -04:00
Joseph Doherty fba3d09eed docs(observability): current-state x3 + GAPS + README
Complete the observability normalization component docs:

- components/observability/current-state/otopcua/CURRENT-STATE.md — full
  OTel SDK (metrics + tracing) + Prometheus; 7 otopcua.* instruments + 2
  spans; Serilog with driver-scope LogContextEnricher; no Resource/service.name
  anywhere; tracing pipeline wired but no exporter; adoption plan: AddZbTelemetry
  gains shared Resource + trace↔log correlation; LogContextEnricher kept bespoke.

- components/observability/current-state/mxaccessgw/CURRENT-STATE.md — 20
  hand-rolled instruments (13 counters, 3 histograms ms-unit, 4 gauges) in
  GatewayMetrics.cs; no OTel SDK → metrics never export; MEL logging with
  GatewayLogScope correlation and GatewayLogRedactor; adoption plan: in-pass
  MEL → AddZbSerilog migration (LogContext correlation, ILogRedactor seam) +
  AddZbTelemetry wires OTel SDK so GatewayMetrics finally exports.

- components/observability/current-state/scadabridge/CURRENT-STATE.md —
  OpenTelemetry.Api is a CVE-patch override only (zero instrumentation); Serilog
  with SiteId/NodeRole/NodeHostname enrichers (strongest set in family); adoption
  plan: replace CVE ref with AddZbTelemetry; adopt AddZbSerilog (LoggerConfigurationFactory
  deleted); add first scadabridge.* instruments.

- components/observability/GAPS.md — divergence table across §1 Resource (P1,
  nobody), §2 metrics export (P1, MxGateway invisible), §3 MxGateway MEL→Serilog
  (P1, in-pass done), §4 trace↔log correlation, §5 ms→s unit, §6 Meter naming,
  §7 standard instrumentation, §8 Serilog version, §9 ScadaBridge zero
  instrumentation; 11-item prioritized backlog.

- components/observability/README.md — overview, per-project status table
  (OTel today / metrics / tracing / logging / enrichers / adoption status),
  normalized vs. left-per-project boundary, 2-package structure, component status.
2026-06-01 07:23:08 -04:00
Joseph Doherty 7d243890ed docs(observability): spec + METRIC-CONVENTIONS + ZB.MOM.WW.Telemetry shared contract
Author the three normalization docs for the observability component:
- components/observability/spec/SPEC.md — Section 0 scope (normalized vs. per-project),
  AddZbTelemetry pipeline, shared Resource attribute set, standard instrumentation baseline,
  exporter conventions, Serilog two-stage bootstrap with identity enrichers and
  TraceContextEnricher, ILogRedactor redaction seam, per-project migration table, and
  acceptance criteria.
- components/observability/spec/METRIC-CONVENTIONS.md — meter naming convention (app
  namespace; MxGateway.Server flagged as convergence target), instrument naming pattern
  (<app>.<subsystem>.<event>), mandatory duration unit = seconds (MxGateway ms histograms
  flagged), Resource attribute set table, standard instrumentation baseline, and per-app
  instrument tables (OtOpcUa 7 instruments + 2 spans; MxGateway 13 counters / 3 histograms
  / 4 gauges; ScadaBridge TBD).
- components/observability/shared-contract/ZB.MOM.WW.Telemetry.md — paper API for the two
  packages: ZbTelemetryOptions, ZbExporter enum, AddZbTelemetry (IHostApplicationBuilder +
  IServiceCollection overloads), ZbResource.Build, MapZbMetrics; AddZbSerilog,
  ZbLogEnricherNames constants, TraceContextEnricher, ILogRedactor, RedactionEnricher.
  Consumer matrix and open contract questions included.
2026-06-01 07:19:38 -04:00
Joseph Doherty 54654a49af chore(audit): scaffold ZB.MOM.WW.Audit solution 2026-06-01 07:19:36 -04:00
Joseph Doherty 76295695ee docs(health): align shared-contract to shipped API + per-lib CLAUDE.md + cleanup
- Contract: DatabaseHealthCheck<TContext> ctor now shows IServiceProvider (resolves
  IDbContextFactory<TContext> when registered, else a scoped TContext; pool-safe)
- Contract: RequireActiveNode gains retryAfterSeconds = 5 default parameter
- Packages: remove dangling AspNetCore.HealthChecks.UI.Client PackageVersion (no
  csproj referenced it)
- Tests: fix CS8625 in RoleLessCases — use object?[] so null role rows compile
  warning-free under Nullable=enable
- Add ZB.MOM.WW.Health/CLAUDE.md (packages, responsibilities, consumer matrix,
  build/test/pack commands, status + pointer to components/health/)
2026-06-01 07:17:18 -04:00
Joseph Doherty 6588e15f57 docs(audit): fix canonical record field count (10 not 8) + drop BCL-only overstatement (review fixes) 2026-06-01 07:16:18 -04:00
Joseph Doherty 0c087d150d feat(health): pack ZB.MOM.WW.Health 0.1.0 + README + register health component in indexes
- Added PackageTags to all 3 library csproj files (health-checks;aspnetcore/akka/efcore;scada;wonderware;zb-mom-ww)
- Full solution dotnet test: 58 tests green (32 Akka + 20 core + 6 EFCore)
- dotnet pack -c Release produces ZB.MOM.WW.Health.0.1.0.nupkg, ZB.MOM.WW.Health.Akka.0.1.0.nupkg, ZB.MOM.WW.Health.EntityFrameworkCore.0.1.0.nupkg; artifacts/ not committed
- ZB.MOM.WW.Health/README.md: overview, packages table, consumer matrix, versioning, build/test/pack instructions, status note
- components/README.md: Health row added to component registry
- CLAUDE.md: Health row in Component-normalization table + Health paragraph; intro updated from "two pieces" to "three pieces"
- upcoming.md: Health checks item checked off with pointer to components/health/ and ZB.MOM.WW.Health/
- components/health/README.md: status updated from "Draft / scaffolded / follow-on" to "Built @ 0.1.0"
2026-06-01 07:09:14 -04:00
Joseph Doherty 69c1be943e docs(audit): README + GAPS adoption backlog 2026-06-01 07:08:31 -04:00
Joseph Doherty ef234d3574 docs(audit): shared-contract ZB.MOM.WW.Audit 2026-06-01 07:08:31 -04:00
Joseph Doherty 8f0b70d12f docs(audit): spec + event-model 2026-06-01 07:04:54 -04:00
Joseph Doherty 1c2b23cbbb refactor(health.akka): review polish (internal decision helper, role guard, factory results, test coverage) + fix SPEC §4 gate description 2026-06-01 07:04:29 -04:00
Joseph Doherty edbc79204f refactor(health.ef): review polish (timer release, timeout test, provider disposal, drop unused dep)
- Eagerly call CancelAfter(InfiniteTimeSpan) after a successful probe so the pending OS
  timer is released on the happy path rather than held for the full timeout window.
- Add ProbeTimeout_Unhealthy test: 50 ms timeout with an infinite-blocking probe delegate
  asserts Unhealthy, covering the timeout code path.
- Fix ProbeQueryThrows_Unhealthy to use Task.FromException rather than a synchronous throw,
  accurately modelling a faulted async delegate.
- Wrap all BuildServiceProvider() results in await using so ServiceProvider is disposed
  after each test (no DI provider leak).
- Remove unused Microsoft.EntityFrameworkCore.InMemory package reference; tests use
  SQLite only (InMemory CanConnect semantics differ and the package was not exercised).
- Add <remarks> to DatabaseHealthCheck<TContext> noting the scoped-resolution path is
  safe for AddDbContextPool (scope dispose returns context to pool, not destroys it).
2026-06-01 07:03:16 -04:00
Joseph Doherty a7a8f1e493 docs(audit): correct file:line refs + split MxGateway CLI/dashboard action vocab (review fixes) 2026-06-01 07:01:46 -04:00
Joseph Doherty aa2251b93d feat(health): core review fixes (async writer, gRPC cancellation, validation, configurable retry-after) 2026-06-01 07:00:21 -04:00
Joseph Doherty cf277eb7df feat(health.akka): active/leader check with role filter + IActiveNodeGate impl 2026-06-01 06:55:46 -04:00
Joseph Doherty 9c8c1431af docs(audit): current-state ScadaBridge 2026-06-01 06:55:07 -04:00
Joseph Doherty 02cc687556 docs(audit): current-state MxAccessGateway 2026-06-01 06:55:07 -04:00
Joseph Doherty e498bb7c5a docs(audit): current-state OtOpcUa 2026-06-01 06:55:07 -04:00
Joseph Doherty 2dbedce0ac feat(health.ef): generic DatabaseHealthCheck<TContext> 2026-06-01 06:48:20 -04:00
Joseph Doherty 25dd328280 feat(health.akka): cluster health check with configurable status policy 2026-06-01 06:47:29 -04:00
Joseph Doherty 1ab2f32e8e feat(health): gRPC dependency health check 2026-06-01 06:44:05 -04:00
Joseph Doherty 5b82d68ea9 feat(health): IActiveNodeGate seam + RequireActiveNode filter 2026-06-01 06:43:11 -04:00
Joseph Doherty d1b837e718 feat(health): canonical JSON health response writer 2026-06-01 06:42:24 -04:00
Joseph Doherty 5fb579c2f0 docs: implementation plan for ZB.MOM.WW.Audit shared library 2026-06-01 06:39:05 -04:00
Joseph Doherty 18be42d0e2 feat(health): scaffold ZB.MOM.WW.Health solution + Task 4 (tags + three-tier MapZbHealth)
Consolidates the library into the scadaproj repo (matching the ZB.MOM.WW.Auth
convention — tracked in-parent, not a nested repo). 3 dependency-split packages
(core / .Akka / .EntityFrameworkCore) + 3 test projects, .slnx, central PM.
Task 4: ZbHealthTags + MapZbHealth (/health/ready,/active,/healthz). 8/8 tests.
2026-06-01 06:35:39 -04:00
Joseph Doherty 07d5907258 docs(health): resolve spec/contract/gaps consistency (review fixes)
Applies canonical resolutions for eight settled decisions:
- GAPS: remove three stale "Decisions still open" bullets (#1 IActiveNodeGate placement, #2 GrpcChannel type, #3 OtOpcUaCompat named constant)
- Shared contract: AkkaClusterHealthCheck, ActiveNodeHealthCheck constructors take IServiceProvider (lazy ActorSystem, Degraded-when-not-ready)
- Shared contract: AkkaActiveNodeGate takes IServiceProvider; reads SelfMember+leader directly, null-guarded; does not proxy ActiveNodeHealthCheck
- Shared contract: DatabaseHealthCheckOptions.Probe renamed to ProbeQuery; consumer matrix updated
- Shared contract: settled AddZbHealthChecks open question removed (spec §5 is per-project AddHealthChecks)
- SPEC §2.2: OtOpcUaCompat Leaving/Exiting cell updated from — to Degraded + footnote; §2.3 startup-safety note added
- README: status line corrected from "built and tested" to "scaffolded … implementation is follow-on (task #7)"; IActiveNodeGate "left per-project" bullet removed
- OtOpcUa current-state: AddZbHealthChecks → AddHealthChecks().AddCheck<...>(); IClusterRoleInfo note reframed as accepted trade-off
- ScadaBridge current-state: IActiveNodeGate bullet rewritten — interface moves to ZB.MOM.WW.Health on adoption, InboundApiEndpointFilter references shared interface
2026-06-01 06:33:42 -04:00
Joseph Doherty 16540b3001 docs: design for audit normalization component + ZB.MOM.WW.Audit 2026-06-01 06:32:39 -04:00
Joseph Doherty 3d25ee5090 docs(health): current-state x3 + GAPS + README
Code-verified current-state docs for OtOpcUa (three-tier full), ScadaBridge
(two-tier, no /healthz), and MxAccessGateway (bare liveness only / no probes).
GAPS backlog with P1 for MxGateway and convergence items for Akka status policy,
DB probe technique, and response writer. README with per-project status table.
2026-06-01 06:23:53 -04:00
Joseph Doherty 1dc35a8c43 docs(health): spec + ZB.MOM.WW.Health shared contract
Authors components/health/spec/SPEC.md (normalized three-tier endpoint
convention, probe catalog, response-writer contract, migration notes) and
components/health/shared-contract/ZB.MOM.WW.Health.md (paper API for the
3-package library: core, Akka, EntityFrameworkCore).
2026-06-01 06:20:19 -04:00
Joseph Doherty c77df2a2cd docs: implementation plans for ZB.MOM.WW.Health + ZB.MOM.WW.Telemetry
Two TDD plans (one per library, per house precedent) derived from the approved
design, with co-located .tasks.json execution-persistence:

- Health: components/health docs + 3 dependency-split packages (11 tasks)
- Telemetry: components/observability docs + 2 packages (3 OTel signals +
  Serilog) + the MxGateway MEL->Serilog migration (12 tasks)

Each task carries classification / est-time / parallelizable metadata for the
executing-plans workflow.
2026-06-01 06:15:22 -04:00
Joseph Doherty 29b309c6c1 docs: design for health + observability normalization components
Adds the approved brainstorm design for the next two component-normalization
entries (Health #1, Observability #2 from upcoming.md):

- components/health/ -> ZB.MOM.WW.Health (3 dependency-split packages)
- components/observability/ -> ZB.MOM.WW.Telemetry (2 packages, 3 OTel signals
  + shared Serilog bootstrap)

Scope: normalization docs + build both libraries (.NET 10, tested, packed);
one sister-repo touch (MxGateway MEL->Serilog migration); no other app adoption.
Unifying hinge: one identity triple (service.name/site.id/node.role) feeds both
the OTel Resource and the Serilog enrichers.
2026-06-01 06:08:51 -04:00
300 changed files with 169100 additions and 205 deletions
+174 -18
View File
@@ -6,9 +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 two pieces of source itself**
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library and the shared
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit — both the realized output of their
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, 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
@@ -27,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 (~1016×); `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
@@ -81,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**.
@@ -98,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
@@ -117,8 +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/) |
| 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
@@ -130,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).
@@ -142,13 +167,130 @@ 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).
The health component is fully populated: a normalized [`spec`](components/health/spec/SPEC.md), a
[`shared-contract`](components/health/shared-contract/ZB.MOM.WW.Health.md), three
[`current-state`](components/health/current-state/) docs, and an adoption [`GAPS`](components/health/GAPS.md)
backlog. Shared = three-tier endpoint convention (ready/active/healthz) + canonical JSON writer +
`IActiveNodeGate` seam + `GrpcDependencyHealthCheck` + `AkkaClusterHealthCheck` + `ActiveNodeHealthCheck`
+ `DatabaseHealthCheck<TContext>`; left per-project = which probes each app registers,
orchestrator wiring, and ScadaBridge's distributed health-monitoring pipeline.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/)
(.NET 10; 3 packages — `ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`;
58 tests; `dotnet pack` → 3 nupkgs @ 0.1.0).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/health/GAPS.md`](components/health/GAPS.md).
Build/test from `ZB.MOM.WW.Health/`: `dotnet test`. Consumer matrix: MxAccessGateway → core only;
OtOpcUa & ScadaBridge → all three packages.
The observability component is fully populated: a normalized [`spec`](components/observability/spec/SPEC.md),
a [`metric-conventions`](components/observability/spec/METRIC-CONVENTIONS.md) reference, a
[`shared-contract`](components/observability/shared-contract/ZB.MOM.WW.Telemetry.md), three
[`current-state`](components/observability/current-state/) docs, and an adoption [`GAPS`](components/observability/GAPS.md)
backlog. Shared = OTel Resource (service.name/site.id/node.role identity triple) + standard instrumentation
(ASP.NET Core, HttpClient, gRPC client, runtime, process) + Prometheus always-on exporter + OTLP opt-in
+ Serilog two-stage bootstrap + SiteId/NodeRole/NodeHostname enrichers + TraceContextEnricher (trace_id/span_id)
+ ILogRedactor seam; left per-project = application Meters/ActivitySources, sink config, per-operation
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). **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
[`current-state`](components/audit/current-state/) docs, and an adoption [`GAPS`](components/audit/GAPS.md)
backlog. Common ground = canonical `AuditEvent` record + `AuditOutcome` enum + `IAuditWriter` /
`IAuditRedactor` seams + helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`,
`CompositeAuditWriter`, `RedactingAuditWriter`) + `AddZbAudit` DI registration; left per-project =
transport/storage and domain vocabulary. Closes the loop on Auth — audit's `Actor` field = the Auth
principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam convention.
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`.
**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 — 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
Run these from inside each project directory (not from `scadaproj`).
@@ -169,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:
+482
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
+15
View File
@@ -0,0 +1,15 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<!-- Test -->
<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>
+59
View File
@@ -0,0 +1,59 @@
# ZB.MOM.WW.Audit
Canonical audit event model, best-effort writer seam, and redactor seam for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). This is a **library, not a service** — it is linked directly into the consuming application at build time. Transport and storage remain per-project; only the shared record + seams live here.
---
## Packages
| Package | Description | Key Dependencies |
|---|---|---|
| `ZB.MOM.WW.Audit` | Canonical `AuditEvent` record, `AuditOutcome` enum, `IAuditWriter` + `IAuditRedactor` seams, shipped helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`, `CompositeAuditWriter`, `RedactingAuditWriter`), and `AddZbAudit` DI extension. | `Microsoft.Extensions.DependencyInjection.Abstractions` |
---
## Consumer Matrix
| Consumer | ZB.MOM.WW.Audit |
|---|:---:|
| **OtOpcUa** | yes (adoption deferred) |
| **MxAccessGateway** | yes (adoption deferred) |
| **ScadaBridge** | yes (adoption deferred — "align, don't replace") |
Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it".
---
## Auth alignment
`AuditEvent.Actor` is a string today. At adoption time it SHOULD be set to the `ZB.MOM.WW.Auth` principal identifier — this is the "audit closes the loop on Auth" hinge described in the spec. No compile-time dependency on `ZB.MOM.WW.Auth` is introduced here; the alignment is by convention.
---
## Versioning
The single package is versioned from `Directory.Build.props`. The current release is **0.1.0**. A single version bump in `Directory.Build.props` bumps the package.
---
## Building and packing
```bash
# From ZB.MOM.WW.Audit/
dotnet build ZB.MOM.WW.Audit.slnx
dotnet test ZB.MOM.WW.Audit.slnx
# Produce the NuGet package into ./artifacts/
./build/pack.sh
```
---
## Design documentation
Full design docs live in the `components/audit` folder of the SCADA project workspace:
- `~/Desktop/scadaproj-audit/components/audit/spec/SPEC.md` — overall audit specification
- `~/Desktop/scadaproj-audit/components/audit/spec/EVENT-MODEL.md` — field-by-field event model + per-project mapping table
- `~/Desktop/scadaproj-audit/components/audit/shared-contract/ZB.MOM.WW.Audit.md` — public API contract (on paper)
- `~/Desktop/scadaproj-audit/components/audit/GAPS.md` — adoption backlog
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/ZB.MOM.WW.Audit/ZB.MOM.WW.Audit.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ZB.MOM.WW.Audit.Tests/ZB.MOM.WW.Audit.Tests.csproj" />
</Folder>
</Solution>
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# pack.sh — produce the ZB.MOM.WW.Audit NuGet package into ./artifacts.
set -euo pipefail
dotnet pack -c Release -o ./artifacts
+24
View File
@@ -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
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Canonical, transport-agnostic audit record — who did what, when, with what outcome.
/// Required core + optional common fields + a <see cref="DetailsJson"/> extension bag. Each
/// sister app maps its own record onto this; domain vocabularies (channels/kinds/event-types)
/// map into <see cref="Action"/>/<see cref="Category"/>/<see cref="DetailsJson"/> and are not
/// modelled here. See scadaproj/components/audit/spec/EVENT-MODEL.md.
/// </summary>
public sealed record AuditEvent
{
/// <summary>Idempotency key uniquely identifying this audit event.</summary>
public required Guid EventId { get; init; }
/// <summary>When the audited action occurred. Normalized to UTC on assignment.</summary>
/// <remarks>Participates in record value-equality as a normalized instant: two events whose
/// <c>OccurredAtUtc</c> denote the same instant at different offsets (e.g. <c>12:00+05:00</c> and
/// <c>07:00Z</c>) compare equal and share a hash code. Relevant to consumers that dedup/key on
/// <see cref="AuditEvent"/> value-equality.</remarks>
public required DateTimeOffset OccurredAtUtc
{
get => _occurredAtUtc;
init => _occurredAtUtc = value.ToUniversalTime();
}
private readonly DateTimeOffset _occurredAtUtc;
/// <summary>Who performed the action (identity string; the ZB.MOM.WW.Auth principal at adoption).</summary>
public required string Actor { get; init; }
/// <summary>What was done — a verb/event-type string.</summary>
public required string Action { get; init; }
/// <summary>Normalized outcome.</summary>
public required AuditOutcome Outcome { get; init; }
/// <summary>Optional subsystem/grouping for the action.</summary>
public string? Category { get; init; }
/// <summary>Optional target of the action (resource/method/connection).</summary>
public string? Target { get; init; }
/// <summary>Optional node that emitted the event.</summary>
public string? SourceNode { get; init; }
/// <summary>Optional correlation id joining this row to its originating request/workflow.</summary>
public Guid? CorrelationId { get; init; }
/// <summary>Optional JSON extension carrying project-specific fields.</summary>
public string? DetailsJson { get; init; }
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Normalized outcome of an audited action.</summary>
public enum AuditOutcome
{
/// <summary>The action completed successfully.</summary>
Success,
/// <summary>The action failed due to an error.</summary>
Failure,
/// <summary>The action was rejected by authentication/authorization.</summary>
Denied,
}
@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace ZB.MOM.WW.Audit;
/// <summary>DI helpers for ZB.MOM.WW.Audit.</summary>
public static class AuditServiceCollectionExtensions
{
/// <summary>
/// Registers safe defaults — <see cref="NullAuditRedactor"/> and <see cref="NoOpAuditWriter"/> —
/// using TryAdd so a consumer that has already registered a real writer/redactor wins. Consumers
/// compose <see cref="RedactingAuditWriter"/>/<see cref="CompositeAuditWriter"/> around their own sink.
/// </summary>
public static IServiceCollection AddZbAudit(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IAuditRedactor>(NullAuditRedactor.Instance);
services.TryAddSingleton<IAuditWriter>(NoOpAuditWriter.Instance);
return services;
}
}
@@ -0,0 +1,28 @@
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>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;
/// <summary>Creates a composite over the given writers.</summary>
public CompositeAuditWriter(IEnumerable<IAuditWriter> inner)
{
ArgumentNullException.ThrowIfNull(inner);
_inner = inner.ToArray();
}
/// <inheritdoc />
public async Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
foreach (var writer in _inner)
{
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
catch { /* best-effort seam: a failing writer (incl. cancellation) must not stop the others or the caller */ }
}
}
}
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Filters an <see cref="AuditEvent"/> between construction and persistence — truncates oversized
/// fields and scrubs sensitive content. Pure function: returns a filtered COPY and MUST NOT throw
/// (over-redact on internal failure). Shaped to mirror Telemetry's <c>ILogRedactor</c> so a future
/// ZB.MOM.WW.Hosting aggregator can wire both consistently; intentionally has no dependency on it.
/// </summary>
public interface IAuditRedactor
{
/// <summary>Apply the configured truncation/redaction policy and return a filtered copy.</summary>
AuditEvent Apply(AuditEvent rawEvent);
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Best-effort sink for <see cref="AuditEvent"/>s. Implementations MUST swallow/log internal
/// failures rather than propagating them — a failed audit write must never abort the
/// user-facing action that produced it.
/// </summary>
public interface IAuditWriter
{
/// <summary>Persist an audit event. Best-effort; must not throw to the caller.</summary>
Task WriteAsync(AuditEvent evt, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Writer that discards events. Default when audit is disabled, and useful in tests.</summary>
public sealed class NoOpAuditWriter : IAuditWriter
{
/// <summary>Shared singleton instance.</summary>
public static readonly NoOpAuditWriter Instance = new();
private NoOpAuditWriter() { }
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => Task.CompletedTask;
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Identity redactor — returns the event unchanged. The default when no policy is configured.</summary>
public sealed class NullAuditRedactor : IAuditRedactor
{
/// <summary>Shared singleton instance.</summary>
public static readonly NullAuditRedactor Instance = new();
private NullAuditRedactor() { }
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent;
}
@@ -0,0 +1,24 @@
namespace ZB.MOM.WW.Audit;
/// <summary>Decorator: applies an <see cref="IAuditRedactor"/>, then delegates to an inner <see cref="IAuditWriter"/>.</summary>
public sealed class RedactingAuditWriter : IAuditWriter
{
private readonly IAuditRedactor _redactor;
private readonly IAuditWriter _inner;
/// <summary>Creates the decorator around <paramref name="inner"/> using <paramref name="redactor"/>.</summary>
public RedactingAuditWriter(IAuditRedactor redactor, IAuditWriter inner)
{
ArgumentNullException.ThrowIfNull(redactor);
ArgumentNullException.ThrowIfNull(inner);
_redactor = redactor;
_inner = inner;
}
/// <inheritdoc />
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(evt);
return _inner.WriteAsync(_redactor.Apply(evt), ct);
}
}
@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.Audit;
/// <summary>
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
/// 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
{
private readonly TruncatingAuditRedactorOptions _options;
/// <summary>Creates the redactor with the given options (defaults when null).</summary>
public TruncatingAuditRedactor(TruncatingAuditRedactorOptions? options = null)
=> _options = options ?? new TruncatingAuditRedactorOptions();
/// <inheritdoc />
public AuditEvent Apply(AuditEvent rawEvent)
{
try
{
return rawEvent with
{
Target = Truncate(rawEvent.Target, _options.MaxTargetLength),
DetailsJson = Truncate(rawEvent.DetailsJson, _options.MaxDetailsJsonLength),
};
}
catch
{
// 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];
return string.Concat(value.AsSpan(0, max - marker.Length), marker);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit;
/// <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; init; } = 4096;
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
public int MaxTargetLength { get; init; } = 512;
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
public string TruncationMarker { get; init; } = "…[truncated]";
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<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>
<PackageId>ZB.MOM.WW.Audit</PackageId>
<Authors>ZB.MOM.WW</Authors>
<Description>Canonical audit event model + best-effort writer and redactor seams for the ZB.MOM.WW SCADA family.</Description>
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</PackageProjectUrl>
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit</RepositoryUrl>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,67 @@
namespace ZB.MOM.WW.Audit.Tests;
public class AuditEventTests
{
private static AuditEvent Minimal() => new()
{
EventId = Guid.NewGuid(),
OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "alice",
Action = "ConfigPublished",
Outcome = AuditOutcome.Success,
};
[Fact]
public void Required_core_fields_round_trip()
{
var id = Guid.NewGuid();
var evt = Minimal() with { EventId = id, Actor = "svc", Action = "ApiCall", Outcome = AuditOutcome.Denied };
Assert.Equal(id, evt.EventId);
Assert.Equal("svc", evt.Actor);
Assert.Equal("ApiCall", evt.Action);
Assert.Equal(AuditOutcome.Denied, evt.Outcome);
}
[Fact]
public void OccurredAtUtc_is_normalized_to_utc()
{
var local = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5));
var evt = Minimal() with { OccurredAtUtc = local };
Assert.Equal(TimeSpan.Zero, evt.OccurredAtUtc.Offset);
Assert.Equal(local.UtcDateTime, evt.OccurredAtUtc.UtcDateTime);
}
[Fact]
public void Optional_fields_default_to_null()
{
var evt = Minimal();
Assert.Null(evt.Category);
Assert.Null(evt.Target);
Assert.Null(evt.SourceNode);
Assert.Null(evt.CorrelationId);
Assert.Null(evt.DetailsJson);
}
[Fact]
public void Records_with_same_values_are_equal()
{
var id = Guid.NewGuid();
var when = DateTimeOffset.UtcNow;
AuditEvent Make() => new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
Assert.Equal(Make(), Make());
}
[Fact]
public void Same_instant_at_different_offset_compares_equal()
{
// Guards the UTC-normalizing init-setter: if OccurredAtUtc is ever "simplified" back to a
// plain auto-property, these two (same instant, different offset) would stop comparing equal.
var id = Guid.NewGuid();
var utc = new DateTimeOffset(2026, 6, 1, 7, 0, 0, TimeSpan.Zero);
var plus5 = new DateTimeOffset(2026, 6, 1, 12, 0, 0, TimeSpan.FromHours(5)); // same instant as utc
AuditEvent With(DateTimeOffset when) =>
new() { EventId = id, OccurredAtUtc = when, Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
Assert.Equal(With(utc), With(plus5));
Assert.Equal(With(utc).GetHashCode(), With(plus5).GetHashCode());
}
}
@@ -0,0 +1,32 @@
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.Audit.Tests;
public class AuditServiceCollectionExtensionsTests
{
[Fact]
public void Registers_null_redactor_and_noop_writer_by_default()
{
var sp = new ServiceCollection().AddZbAudit().BuildServiceProvider();
Assert.IsType<NullAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
Assert.IsType<NoOpAuditWriter>(sp.GetRequiredService<IAuditWriter>());
}
[Fact]
public void Does_not_override_a_preregistered_writer()
{
var services = new ServiceCollection();
services.AddSingleton<IAuditWriter>(new CompositeAuditWriter(System.Array.Empty<IAuditWriter>()));
var sp = services.AddZbAudit().BuildServiceProvider();
Assert.IsType<CompositeAuditWriter>(sp.GetRequiredService<IAuditWriter>());
}
[Fact]
public void Does_not_override_a_preregistered_redactor()
{
var services = new ServiceCollection();
services.AddSingleton<IAuditRedactor>(new TruncatingAuditRedactor());
var sp = services.AddZbAudit().BuildServiceProvider();
Assert.IsType<TruncatingAuditRedactor>(sp.GetRequiredService<IAuditRedactor>());
}
}
@@ -0,0 +1,69 @@
namespace ZB.MOM.WW.Audit.Tests;
public class CompositeAuditWriterTests
{
private sealed class RecordingWriter : IAuditWriter
{
public int Count;
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Count++; return Task.CompletedTask; }
}
private sealed class ThrowingWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new InvalidOperationException("boom");
}
private sealed class CancellingWriter : IAuditWriter
{
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) => throw new OperationCanceledException();
}
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
[Fact]
public async Task Fans_out_to_all_writers()
{
var a = new RecordingWriter(); var b = new RecordingWriter();
await new CompositeAuditWriter(new IAuditWriter[] { a, b }).WriteAsync(Evt());
Assert.Equal(1, a.Count);
Assert.Equal(1, b.Count);
}
[Fact]
public async Task One_failing_writer_does_not_stop_the_others()
{
var after = new RecordingWriter();
var sut = new CompositeAuditWriter(new IAuditWriter[] { new ThrowingWriter(), after });
await sut.WriteAsync(Evt()); // must not throw
Assert.Equal(1, after.Count);
}
[Fact]
public async Task Cancellation_does_not_surface_to_the_caller()
{
// 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 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);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit.Tests;
public class NoOpAuditWriterTests
{
[Fact]
public async Task WriteAsync_completes_without_error()
{
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success };
await NoOpAuditWriter.Instance.WriteAsync(evt);
}
}
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Audit.Tests;
public class NullAuditRedactorTests
{
[Fact]
public void Apply_returns_input_unchanged()
{
var evt = new AuditEvent { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "{\"k\":1}" };
Assert.Same(evt, NullAuditRedactor.Instance.Apply(evt));
}
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.Audit.Tests;
public class RedactingAuditWriterTests
{
private sealed class CapturingWriter : IAuditWriter
{
public AuditEvent? Last;
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Last = evt; return Task.CompletedTask; }
}
private sealed class StampRedactor : IAuditRedactor
{
public AuditEvent Apply(AuditEvent rawEvent) => rawEvent with { DetailsJson = "redacted" };
}
private static AuditEvent Evt() => new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success, DetailsJson = "secret" };
[Fact]
public async Task Inner_writer_receives_the_redacted_event()
{
var inner = new CapturingWriter();
var sut = new RedactingAuditWriter(new StampRedactor(), inner);
await sut.WriteAsync(Evt());
Assert.Equal("redacted", inner.Last!.DetailsJson);
}
}
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.Audit.Tests;
public class TruncatingAuditRedactorTests
{
private static AuditEvent Evt(string? details, string? target = null) => new()
{
EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow,
Actor = "a", Action = "x", Outcome = AuditOutcome.Success,
DetailsJson = details, Target = target,
};
[Fact]
public void Short_values_pass_through_unchanged()
{
var r = new TruncatingAuditRedactor(new() { MaxDetailsJsonLength = 100 });
var evt = Evt("small");
Assert.Equal("small", r.Apply(evt).DetailsJson);
}
[Fact]
public void Oversized_details_are_truncated_with_marker()
{
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 10, TruncationMarker = "~" };
var r = new TruncatingAuditRedactor(opts);
var result = r.Apply(Evt(new string('x', 50)));
Assert.Equal(10, result.DetailsJson!.Length);
Assert.EndsWith("~", result.DetailsJson);
}
[Fact]
public void Oversized_target_is_truncated()
{
var r = new TruncatingAuditRedactor(new() { MaxTargetLength = 5, TruncationMarker = "" });
var result = r.Apply(Evt(null, target: "abcdefghij"));
Assert.Equal(5, result.Target!.Length);
}
[Fact]
public void Null_fields_are_left_null()
{
var r = new TruncatingAuditRedactor();
var result = r.Apply(Evt(null));
Assert.Null(result.DetailsJson);
Assert.Null(result.Target);
}
[Fact]
public void Marker_longer_than_max_clips_the_marker_itself()
{
// Misconfiguration: marker longer than the cap. Must not throw; clips to the first max chars.
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 3, TruncationMarker = "…[truncated]" };
var r = new TruncatingAuditRedactor(opts);
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);
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.Audit\ZB.MOM.WW.Audit.csproj" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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()
{
@@ -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);
}
+482
View File
@@ -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
+77
View File
@@ -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>
+112
View File
@@ -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>"&lt;field&gt; &lt;reason&gt;"</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>
@@ -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
}
}
@@ -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);
}
}
@@ -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>
@@ -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; }
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
@@ -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);
@@ -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);
@@ -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);
}
@@ -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);
}
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More