Compare commits
105 Commits
2e545b50da
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66c3e060ae | |||
| 19d59915ea | |||
| 6b737b12b1 | |||
| 484d530e65 | |||
| fd180e576f | |||
| 967da5b43b | |||
| 6ad15fcf93 | |||
| 795af1da71 | |||
| 65c6b99cf2 | |||
| 420c2e0783 | |||
| 3cacdca861 | |||
| ade49c0935 | |||
| 02480d4fbc | |||
| 87bb647efb | |||
| a0cec479b6 | |||
| 91baba8fcc | |||
| 9688707d82 | |||
| 509114360d | |||
| cfd371ce18 | |||
| 51a956a878 | |||
| 2a3baae301 | |||
| 10fc22b87e | |||
| 8825cd966f | |||
| 69a7f8e1b5 | |||
| 01a6313fe7 | |||
| e7cfa21fda | |||
| 68f1b56acf | |||
| b4da0deacb | |||
| 180403cb17 | |||
| 67c182ce18 | |||
| 05042e5e36 | |||
| aed5fbf046 | |||
| 00ef22823f | |||
| c05a6d93b0 | |||
| ec2615a9d2 | |||
| 2a0b34e056 | |||
| 4555a3bdad | |||
| 681606dad0 | |||
| aa7093b53b | |||
| 17cb91a315 | |||
| 0adfb244b2 | |||
| c6e60cb6c4 | |||
| c713925ac7 | |||
| 0b056c6fee | |||
| 484b176cd6 | |||
| dd1eea2106 | |||
| 63cc772e8d | |||
| 3b4d81e630 | |||
| b6f5eb2535 | |||
| a8f5f66647 | |||
| df21d365ec | |||
| a2b40b7922 | |||
| 3c91f0f813 | |||
| a6c4161719 | |||
| 78ca2a6744 | |||
| 21a95c7f50 | |||
| c43d7ee1ad | |||
| d0b66bf105 | |||
| 44421dc5aa | |||
| 8fa7631ec4 | |||
| b62cfad48d | |||
| 625f207e08 | |||
| 29bfa8fbf9 | |||
| b0582eb66a | |||
| 9090b1b511 | |||
| e3d146aa21 | |||
| 177881c423 | |||
| 3c42069673 | |||
| 00ac436ae2 | |||
| 302d1245cd | |||
| fa0ca66a05 | |||
| 711e8beb2b | |||
| 9ac6ed51d3 | |||
| efd5d881dc | |||
| 0793159cbb | |||
| 51b6f1ab9f | |||
| 29d0c50f07 | |||
| adbf2eb182 | |||
| b392b84f21 | |||
| 5c73be486d | |||
| 308742b7a6 | |||
| 8cfe076012 | |||
| 646af887d1 | |||
| 1e0bfe03d4 | |||
| 9934bea05a | |||
| d0079ba1a1 | |||
| 4dda768801 | |||
| f6eef9d8eb | |||
| 16a84f9df4 | |||
| 3c92bf54b0 | |||
| fb33924473 | |||
| 46fa06a612 | |||
| 5ee31dbf7b | |||
| 98868a60d0 | |||
| 3903c9a196 | |||
| ef487957b2 | |||
| 895e881807 | |||
| 54f111e8b1 | |||
| 370a9eea96 | |||
| 4988c24618 | |||
| fe6216c533 | |||
| 10990a43c8 | |||
| e538ea36e3 | |||
| 63e6d3f867 | |||
| c72271aa8a |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: sister-project-history
|
||||
description: Use when you need the detailed change history, live-gate records, redundancy/auth/ACL behaviour, or ScadaBridge consumer-impact notes for a sister project (OtOpcUa, HistorianGateway, BTDB) — the scadaproj root CLAUDE.md keeps only a short summary row per project; the full narrative lives in this directory.
|
||||
---
|
||||
|
||||
# Sister project history
|
||||
|
||||
The root `CLAUDE.md` index keeps one short row per sister project. The full narrative rows that used to live
|
||||
in that table were moved here **verbatim** on 2026-09-03 (they were ~57k characters loading into every session):
|
||||
|
||||
- `otopcua.md` — OtOpcUa: v3 dual-namespace address space, every driver, redundancy/mesh program, data-plane auth + ACL enforcement, deferment register, the authoring-vs-deploy-gate parity arc, and the per-issue consumer-impact record for ScadaBridge.
|
||||
- `historiangateway.md` — HistorianGateway: ArchReview cycles, pending.md items, session pools, datatype gaps, SQL-path workarounds, PR/commit history.
|
||||
- `btdb.md` — BTDB (Batch Tracking Database, added 2026-09-03): schema + sproc write contract, Historian/CLR coupling, Galaxy/MESAPI/ScadaBridge edges, the DARS `BatchTracker_DB` fork, and the gap analysis.
|
||||
|
||||
Read the file for the project you are working against. When a sister repo tells you to "update its entry in
|
||||
`scadaproj/CLAUDE.md`", append the detail to the file here and keep the index row to a few lines.
|
||||
@@ -0,0 +1,207 @@
|
||||
# BTDB (Batch Tracking Database) — index history + analysis
|
||||
|
||||
> Added 2026-09-03 as a sister project. **This file is the authoritative place for BTDB detail — append here and keep the root index row short.** Everything below was verified from source on 2026-09-03 unless marked _(inferred)_.
|
||||
|
||||
| Project | Location | Stack | Repo | Summary |
|
||||
|---|---|---|---|---|
|
||||
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` (case-insensitive FS: also `BTDB`) | SQL Server 2016+ SSDT project + SQL CLR (net472) · manager: **Blazor Web App net10.0** on `feat/manager-blazor` (§8; `master` still carries the EOL net6.0 MVC app) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03.zmr.zimmer.com:3000` (`master` @ `d147c10e`, 2023-07-26). **Not** on `gitea.dohertylan.com`. | The `BT` SQL Server database that is the plant's **batch/cycle/work-order system of record for data collection**: Galaxy machine objects call its `StartMachineBatch`/`StartMachineCycle`/`End*`/`AddMachineBatchWorkOrder`/`AddOtherData*` sprocs; reports pull time-series per batch out of the AVEVA Historian through its CLR sprocs (`OPENQUERY(INSQL, … WideHistory …)` + `Runtime.dbo.*`); MESAPI and ScadaBridge's inbound API resolve `SAPID → Machine.Code` from `dbo.Machine`. `BTDBManager` is a small Windows-service MVC site for machine/group config, batch edits (audited) and cross-environment config import. |
|
||||
|
||||
## 1. What is on disk, and where the truth is
|
||||
|
||||
- `~/Desktop/btdb` is **not a git checkout** — no `.git`, no `CLAUDE.md`, no `README`. It is a file-for-file snapshot of `ZimmerBiomet/BTDB` `master` (the only branch) at its head commit `d147c10e` (2023-07-26, "Update notification system tables to add urgent flag"). Verified by comparing the Gitea tree with the local tree: 170 vs 168 files, the only difference being the remote's `.gitattributes`/`.gitignore`. The repo's `updated_at` of 2024-11-07 is not a commit (the branch head is still 2023-07-26).
|
||||
- `~/Desktop/DARS/WW_ARCH/BatchTracker_DB/` is a **fork of the `Database` project only** (no Manager), modified 2026-08-13, also not under git (DARS is not a repo). Substantive differences vs the snapshot, all matching the DARS sprint-1 spec `DARS/sprints/sprint1/bt_db_changes.md`:
|
||||
- new table `MachineBatchTransfer` (source/dest batch chain for mid-run reactor failure; `TransferType` CHECK `'mid_run_failure'`; three indexes) + sproc `AddMachineBatchTransfer` (linear-chain guard);
|
||||
- `MachineBatchWO` gains `ContainerID NVARCHAR(64) NULL` + `MoveOutTimestamp DATETIME2 NULL` with filtered indexes (Camstar container traceability; mirrors the `SAPID`/`ZTag` idiom);
|
||||
- new CLR sproc `GetLinkedBatchTSV` (+ `_Wrapper`) that walks the transfer chain and stitches TSVs, and `LinkedBatchAlarm_Wrapper`;
|
||||
- `StartMachineBatch.sql`, `MachineBatch.sql`, `AuditLogMachineBatch.sql` differ **only** in BOM/CRLF → LF normalization (no logic change).
|
||||
- Reference doc for the schema: `DARS/WW_ARCH/BatchTracker_Reference.md` (states 22 tables / 35 SQL sprocs / 4 CLR sprocs / 5 CLR UDFs / 6 table types / 1 aggregate).
|
||||
- **Resolved 2026-09-03 — the divergence is closed locally:** `~/Desktop/btdb` was turned into a real checkout (`git init` + fetch of `ZimmerBiomet/BTDB`, `master` tracking `origin/master`; the snapshot was tree-identical apart from exec bits copied from Windows, which were cleared). The DARS fork's transfer-chain work was migrated onto branch **`feat/machine-batch-transfer`** as one commit: the five new objects, the two `MachineBatchWO` columns, and the `.sqlproj` wiring **including the `Compile` entry for `GetLinkedBatchTSV.cs` that the fork had omitted** (without it the CLR sproc is never built and its wrapper fails at deploy). The BOM/CRLF re-saves were not carried over. The sprint-1 plan (`bt_db_changes.md`, overview, diagrams) and `BatchTracker_Reference.md` now live under `btdb/docs/`. **The branch is local only — not pushed to the corporate Gitea.** `DARS/WW_ARCH/BatchTracker_DB/` is now superseded and should be deleted or replaced by a pointer once the branch is pushed. A second commit on the same branch carried the two columns through the Manager's batch-edit path: `MachineBatchWOType` (appended last — the TVP binds by ordinal and `ToDataTable` emits properties in declaration order, so the view model's property order must keep matching), `AuditLogMachineBatchWO` (`Prev_`/current pairs), the DTOs and view model, `SaveBatchEdit.sql` (MERGE change-detection with `COALESCE` because both columns are NULL-able, OUTPUT and the three audit inserts), and the Kendo work-order grid in `Views/BatchEdit/Edit.cshtml` (masked timestamp editor like the cycle grid). `LookupBatchInfo.sql` is `SELECT *` so it needed nothing. Not touched: the Telerik `BatchEditAuditLogReport.trdp` (binary package; open it in the Telerik designer to add the audit columns) and the batch search grid (`SearchBatches.sql`), which still lists only work-order numbers. Nothing here was built or run: the Manager needs Telerik + Windows, and the SSDT project needs MSBuild; a throwaway compile of the Manager retargeted to .NET 10 on the Mac showed no errors in the touched files.
|
||||
|
||||
## 2. Solution shape
|
||||
|
||||
`BTDB.sln` (VS 2022) = two projects:
|
||||
|
||||
| Project | Kind | Notes |
|
||||
|---|---|---|
|
||||
| `Database/Database.sqlproj` | SSDT, `Sql130DatabaseSchemaProvider` (SQL Server 2016+), CLR assembly targets **net472**, C# namespace `BT` | 22 tables, 6 table types, ~35 T-SQL sprocs, 3 CLR sprocs (`GetTSV`, `GetMachineBatchTSV`, `GetCycleTSV`), 5 CLR UDFs (`GetAggregateValues`, `CheckTriggered`, `WhenTriggered`, `WhenCleared`, `StringCompare`), 1 CLR aggregate (`StringJoin`), 1 T-SQL TVF (`GenerateShifts`), 1 trigger (`TrgNotification` → `AuditLogNotification`). Requires `CLR enabled` on the instance. |
|
||||
| `Manager/BTDBManager.csproj` (**`master` only — deleted on `feat/manager-blazor`, see §8**) | ASP.NET Core MVC, **net6.0** (out of support since 2024-11), Dapper 2.0, `System.Data.SqlClient`, `System.DirectoryServices` (Windows-only LDAP), `Microsoft.Extensions.Hosting.WindowsServices`, Kendo UI + Bootstrap, **Telerik Reporting R2 2022** referenced by an absolute `HintPath` into `Program Files (x86)` | Kestrel `http://*:9876`; runs as a Windows service; 13 embedded `.sql` query resources; 3 `.trdp` Telerik audit reports. `Manager/install.bat`/`uninstall.bat` are **leftovers from a different service** (`WWNotificationSystem.exe`) and do not install the Manager. |
|
||||
|
||||
## 3. Database model (what the family writes and reads)
|
||||
|
||||
**Core hierarchy:** `Machine` (`Code` unique; optional unique `ZTag`, `SAPID`; `TimeZone`; `MultipleBatch`/`MultipleCycle` flags; `OtherData` JSON) → `MachineBatch` (start/end operator + UTC timestamps, `OtherData`) → `MachineCycle` (same shape) and `MachineBatchWO` (`WorkOrderNumber`, `ItemNumber`). `MachineGroup` + `MachineGroupMembership` give roll-ups (DARS models one `Machine` per reactor *side*, one `MachineGroup` per unit). `MachineAlarm` is a per-machine alarm catalog (`Severity` 0–999, `FlaggedForMES`).
|
||||
|
||||
**Lifecycle sprocs (the write contract):**
|
||||
|
||||
| Sproc | Behaviour |
|
||||
|---|---|
|
||||
| `StartMachineBatch(@Code, @OperatorID, @o_MachineBatchID OUT)` | Looks up machine by `Code`; unless `MultipleCycle`/`MultipleBatch`, **auto-closes** any open cycle/batch first; inserts with `GETUTCDATE()`. Throws 50000 if machine unknown. |
|
||||
| `EndMachineBatch(@MachineBatchID, @EndOperatorID)` | Closes the open cycle too; throws if not found / already closed. |
|
||||
| `StartMachineCycle(@MachineBatchID, @OperatorID, @o_MachineCycleID OUT)` / `EndMachineCycle` | Cycle must belong to an open batch. |
|
||||
| `AddMachineBatchWorkOrder(@MachineBatchID, @WorkOrderNumber, @ItemNumber, @o OUT)` | Only on open batches. |
|
||||
| `AddOtherDataMachineBatch` / `…MachineBatchWorkOrder` / `…MachineCycle (@ID, @DataKey, @DataValue)` | `JSON_MODIFY` into `OtherData` — via **dynamic SQL that concatenates `@DataKey`/`@DataValue`** into the statement (`sp_executesql` of a built string). Injection surface; callers today are trusted Galaxy scripts. |
|
||||
|
||||
All timestamps are `GETUTCDATE()`; `Machine.TimeZone` is used only at retrieval time (`wwTimeZone=` in report wrappers). This matches the family's all-UTC rule.
|
||||
|
||||
**Historian coupling (the read contract):** the CLR code resolves a machine-relative tag (`<Code>.<local>`; `*.<attr>` shorthand; `|literal|` static columns) to the Historian's global tagname via `Runtime.dbo.TagExtendedPropertyInfo` (`HierarchicalName`), then runs `SELECT … FROM OPENQUERY(INSQL, 'SELECT DateTime, [tag]… FROM WideHistory WHERE … wwTimeZone="UTC" AND <queryParams>')` (`wwRetrievalMode`, `wwResolution`, `wwTimeZone` are passed through **verbatim** from `@queryParams`). Discrete checks read `Runtime.dbo.DiscreteHistory`; alarm wrappers read `Runtime.dbo.Events` (`IsAlarm=1`, `Alarm_OriginationTime`, `Alarm_DurationMs`, `Alarm_UnAckDurationMs`). **Consequence: the `BT` database must live on (or three-part-name-reach) the Historian's SQL instance with the `INSQL` linked server** — it is co-tenant with `Runtime`, which is why it sits on the `wonder-sql-*` boxes.
|
||||
|
||||
**Report wrappers** are per machine family and are the consumers of the CLR layer: Crest (`CrestWide`/`CrestMinMax`/`CrestAlarm`/`CrestCleaner*`), Elma (`ElmaData`, `Elma_Bulk_Query_Nitric`), `ExtruderWide`, `PultrusionWide`, Haas (`HaasStatusReport`, `HaasAlarm`, `QV_Haas*` = QlikView), Solar (`SolarAlarms`/`SolarEvents`), DryHeat (`DryHeatCycleReport`/`Search`/`BulkReport`), PoncePolyPress, `Z002432Wide`/`Z002433Wide`. `GenerateShifts` builds a 3-shift calendar table for these.
|
||||
|
||||
**Auth tables that are not the Manager's:** `UserAuth`, `UserAuthDetails`, `UserAuthRole`, `ApiKey` are ServiceStack's schema — they belong to **MESAPI/WWSupport** (the Camstar-facing REST service, see `mes-delmia-integration-api.md` §1.1: `ApiKeyAuthProvider` + `LdapAuthProvider`, role `MESAPI`), which shares the `BT` database. The Manager authenticates via LDAP only.
|
||||
|
||||
**Notification tables** (`Notification`, `NotificationLog`, audited by trigger) back the separate `WWNotificationSystem` Windows service, which is **not in this repo**.
|
||||
|
||||
## 4. BTDBManager (the config/edit UI) — `master` state; the replacement is §8
|
||||
|
||||
- **Auth:** cookie scheme; `LdapAuthenticationService` binds `LDAP://nam.zimmer.com` with the user's own credentials via `System.DirectoryServices` (so it only runs on a domain-joined Windows host), reads `memberOf`, and keeps only the three configured group DNs. Policies `READONLY` / `MACHINECONFIG` / `BATCHEDIT` map to `ReadOnlySecurityGroup` / `MachineConfigSecurityGroup` / `BatchEditSecurityGroup`; the checked-in DEV config points all three at `UG_WW_APP_ADMIN_DEV`. A global `AuthorizeFilter` makes every controller (incl. `/Api/GetMachines`, `/Api/GetMachineGroups`) require login. This is exactly the AD-group→role problem the family's `ZB.MOM.WW.Auth` 0.3.0 + `ldap_mapping.md` already solve.
|
||||
- **Features:** Machine CRUD (with group memberships + alarm catalog via TVPs `MachineGroupMembershipType`/`MachineAlarmType`), MachineGroup CRUD, batch search/edit (`SaveBatchEdit` writes `AuditLogBatchEdit` + prev/new `AuditLogMachineBatch`/`…WO`/`…Cycle` rows), **MachineConfigImport** (copies machine/group config from another environment's `BT` — sources `QA` and `PROD` are hard-wired in `appsettings.json`), and Telerik `.trdp` audit reports.
|
||||
- **Secrets are checked in:** `appsettings.json` carries plaintext `wonderapp` SQL passwords for DEV/QA/PROD, and `install.bat` carries a domain service-account password. Treat all of them as compromised-by-repo; do not copy them into any other file or reply. This is the pattern `ZB.MOM.WW.Secrets` (`${secret:}`) exists to replace.
|
||||
- **Environment hosts as recorded in 2023:** DEV `wonder-sql-vd01/BT`, QA `wondersqlqa/BT` (`MultiSubnetFailover`), PROD `wondersqlpd/BT`. ScadaBridge's 2026-06-16 design says the DEV BTDB is on **`wonder-sql-vd03/BT`** (db-connection id 1). `mes-delmia-integration-api.md` also says `vd01`. **Host drift is unresolved** — do not assume either without probing.
|
||||
|
||||
## 5. How BTDB relates to the rest of the family
|
||||
|
||||
```
|
||||
Galaxy machine objects ──(script: EXEC StartMachineBatch / StartMachineCycle /
|
||||
($Reactor_Side etc.) AddMachineBatchWorkOrder / AddOtherData* …)──────────┐
|
||||
▼
|
||||
Camstar MES ──HTTPS──▶ MESAPI/WWSupport ──(SELECT Machine by SAPID; alarm catalog; ┌────────────────────┐
|
||||
(ServiceStack, own UserAuth/ApiKey tables) ────────────────▶ │ SQL Server "BT" │
|
||||
│ (BTDB) │
|
||||
ScadaBridge inbound API ──(Database helper, connection "BTDB": SELECT Code FROM │ co-tenant with │
|
||||
(IpsenMESMoveIn, MES alarm-status) ─────────────────────────────────────────────▶ │ Historian Runtime │
|
||||
ScadaBridge site scripts ──(Database.Connection / CachedWrite — customer scripts)─▶ │ + INSQL linked │
|
||||
└─────────┬──────────┘
|
||||
BTDBManager (config, batch edits, env import) ─────────────────────────────────────▶ │ CLR: OPENQUERY(INSQL) WideHistory,
|
||||
SSRS / QlikView / Telerik reports ──(EXEC *_Wrapper, GetMachineBatchTSV …)──────────▶ │ Runtime.dbo.{TagExtendedPropertyInfo,
|
||||
▼ DiscreteHistory, Events}
|
||||
AVEVA Historian (Runtime DB)
|
||||
```
|
||||
|
||||
- **Galaxy → BT** is the primary write path (batches/cycles/work orders/OtherData JSON). DARS `model_info.md` §: "Existing per-machine BT sprocs (`StartMachineBatch`, `StartMachineCycle`, `GetCycleTSV`, `Alarm_Wrapper`, …) work unchanged" — the sprocs are the contract Galaxy scripts bind to.
|
||||
- **ScadaBridge → BT (verified):** the inbound-API script context has a `Database` helper bound to named db-connections; production defines connection **`BTDB`** (id 1), and the `IpsenMESMoveIn` (design 2026-06-16) and MES alarm-status (2026-06-30) methods do `SELECT Code FROM dbo.Machine WHERE SAPID=@s` before `Route.To(instance)`. Template/site scripts expose `Database.Connection(name)` + `Database.CachedWrite(...)`, so site scripts **can** write batches into BT — but **no checked-in ScadaBridge script calls a BT sproc**; any batch logging is customer-site script configuration, not platform code, and the docker rig does **not** seed a `BT` database (`infra/mssql/setup.sql` creates only `ScadaBridgeConfig` + `ScadaBridgeMachineData`; the `InboundDatabaseHelperTests` use a stand-in). ScadaBridge's inbound API is the designated successor of MESAPI's move-in/move-out surface, which is why it needs the same `SAPID → Code` lookup.
|
||||
- **HistorianGateway ↔ BT:** no direct coupling, but both read `Runtime.dbo.Events` (HG's SQL-path `ReadEvents`) and both need the Historian SQL instance. BT's CLR `GetTSV`/`GetAggregateValues` is a bespoke, in-database equivalent of what HG's `ReadRaw`/aggregate retrieval modes do over gRPC — a candidate for consolidation if the report layer is ever re-platformed.
|
||||
- **OtOpcUa / mxaccessgw:** no coupling.
|
||||
- **Break surface:** the sproc signatures + `Machine.Code`/`SAPID`/`ZTag` semantics (Galaxy scripts, MESAPI, ScadaBridge inbound methods all bind to them), the `OtherData` JSON key conventions (DARS sprint 1 defines ~14 new keys with single writers), and the `INSQL`/`Runtime` co-location assumption.
|
||||
|
||||
## 6. Analysis — gaps and recommendations (2026-09-03)
|
||||
|
||||
1. **Push the branch.** ~~Put both copies under git~~ — done locally 2026-09-03 (`feat/machine-batch-transfer`). Remaining: push it to `ZimmerBiomet/BTDB`, review/merge, decide whether BTDB also gets a mirror on `gitea.dohertylan.com` like the other sisters, then delete the superseded `DARS/WW_ARCH/BatchTracker_DB/` copy.
|
||||
2. **Rotate and externalize the checked-in credentials** (three SQL passwords, one domain account). _Externalised on `feat/manager-blazor` (`${secret:btdb/...}` refs, store outside the content root); rotation is a cut-over step (§8)._ The family standard is `ZB.MOM.WW.Secrets` `${secret:}` with an absolute `Secrets:SqlitePath` outside the content root.
|
||||
3. **Manager re-platform** — _done on `feat/manager-blazor`, see §8_: net6.0 is EOL; Telerik is pinned by an absolute machine path; `System.DirectoryServices` binds the user's own credentials on a domain-joined box. A .NET 10 rewrite on the family libs is mostly assembly: Auth 0.3.0 (LDAP + `UG_WW_*` group→role mapping, `FallbackServers`), Theme 0.4.1, Health 0.3.0 (`MapZbHealth`, so Overview can watch it), Telemetry, Configuration, Audit (the batch-edit audit is a natural `ZB.MOM.WW.Audit` writer), Secrets. Keep Dapper + the embedded queries; replace Telerik `.trdp` with server-side rendering only if the three audit reports are still used.
|
||||
4. **Injection surfaces in the SQL layer:** `AddOtherData*` build dynamic SQL from `@DataKey`/`@DataValue`; `GetTSV`/`GetAggregateValues` interpolate tag names and append `@queryParams` raw into `OPENQUERY`. Trusted callers today, but ScadaBridge scripts and the inbound API widen the caller set. Fix `AddOtherData*` with parameterised `JSON_MODIFY` (no dynamic SQL needed), and whitelist `queryParams` to the three `ww*` keys.
|
||||
5. **ServiceStack auth tables** (`UserAuth*`, `ApiKey`) are MESAPI's, not BT's domain. Once ScadaBridge's inbound API fully replaces MESAPI (Ipsen MoveIn is the first slice; ScadaBridge keys already live in the `zbauth` schema via `Auth.ApiKeys.SqlServer` 0.3.0), those tables are dead weight in the schema and should be dropped from the sqlproj rather than carried forward.
|
||||
6. **Host inventory drift:** 2023 config says DEV BT is on `wonder-sql-vd01`; ScadaBridge's 2026 design says `wonder-sql-vd03`. Probe and record the answer in `deployment.md` §2.1 (which currently has no BT row at all).
|
||||
7. **`install.bat`/`uninstall.bat` are wrong for this project** (they install `WWNotificationSystem`). _Deleted on `feat/manager-blazor`; replaced by `deploy/install-service.bat` (NSSM, service `ZB.MOM.WW.BTDB.Manager`)._
|
||||
8. **CLR + Historian co-location is a hard deployment constraint** the index did not previously record: BT cannot be moved to the app-tier SQL (`wonder-app-vd03,1433`, where ScadaBridge's ConfigDb lives) without breaking every report wrapper. Any consolidation plan must keep BT on the Historian instance or replace the CLR with HistorianGateway calls.
|
||||
|
||||
## 7. Primary commands (Windows only)
|
||||
|
||||
```powershell
|
||||
# from ~/Desktop/btdb (master)
|
||||
dotnet build Manager\BTDBManager.csproj # net6.0; needs Telerik Reporting R2 2022 at the HintPath
|
||||
msbuild Database\Database.sqlproj /p:Configuration=Release # SSDT (VS 2022 + SSDT) → bin\Release\Database.dacpac
|
||||
SqlPackage /Action:Publish /SourceFile:Database\bin\Release\Database.dacpac /TargetServerName:<historian-sql> /TargetDatabaseName:BT
|
||||
# Instance prerequisites: CLR enabled, `INSQL` linked server, `Runtime` DB reachable by three-part name
|
||||
```
|
||||
|
||||
```bash
|
||||
# feat/manager-blazor (any OS for the app; SSDT still Windows-only)
|
||||
./build.sh # restore + build (Release, warnings-as-errors) + test BTDB.slnx
|
||||
dotnet run --project src/ZB.MOM.WW.BTDB.Manager # appsettings.Development.json → local glauth; needs a BT
|
||||
pwsh deploy/publish.ps1 # self-contained win-x64 → deploy/out/ (+ NSSM install scripts)
|
||||
```
|
||||
|
||||
```bash
|
||||
# Local Docker rig (2026-09-04, any OS) — see §9
|
||||
docker/deploy-db.sh # BT on scadabridge-mssql from Database/ (dacpac + SQL CLR); --recreate drops it first
|
||||
docker/deploy.sh # publish FDD on the host → zb-btdb-manager:local, up on :9877 (glauth login enabled)
|
||||
```
|
||||
|
||||
On `master` nothing builds on macOS (SSDT/CLR need Windows MSBuild, the MVC Manager needs Telerik + `System.DirectoryServices`). On the branch the app and its tests build and run on macOS; only `Database.sqlproj` (kept alone in `BTDB.sln`) still needs Windows — but §9's side projects build the same sources into a dacpac and a CLR assembly anywhere, for the local rig.
|
||||
|
||||
## 8. Blazor conversion — branch `feat/manager-blazor` (2026-09-04)
|
||||
|
||||
Plan: `btdb/docs/plans/manager_blazor_conversion.md` (+ `.tasks.json` tracker beside it; mockups `docs/mockups/manager_blazor_mockups.html`). Executed 2026-09-04 in worktree `~/.config/superpowers/worktrees/BTDB/feat-manager-blazor`; **uncommitted and unpushed at the time of writing** — phases 1–3 (scaffold, core, auth, widgets, queries, all pages) plus the local half of phase 4 are done; the DEV-box half of phase 4 (parity run, secrets seed + rotation, NSSM side-by-side, port swap) is written up as a runbook and not yet performed.
|
||||
|
||||
- **Shape on the branch:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor Web App, InteractiveServer, net10.0) + `src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer moved verbatim from the MVC app, hardened) + two xunit/bUnit test projects (334 tests, warnings-as-errors gate via root `Directory.Build.props`, `build.sh`/`build.ps1`). `BTDB.sln` keeps only `Database.sqlproj`. `Manager/` (MVC, Kendo, the three Telerik `.trdp` reports with embedded connection strings, the mis-named `install.bat`) is **deleted**; `/Api/GetMachines`+`GetMachineGroups` are gone (no external consumer — ScadaBridge/MESAPI read `dbo.Machine` by SQL).
|
||||
- **Family libs:** Theme 0.4.1, Health 0.3.0 (`MapZbHealth`; readiness = a `SELECT 1` against `BT` with a 3 s timeout, so `/health/ready` is 503 until the DB answers — register it in Overview), Telemetry + Serilog 0.1.0, Configuration 0.1.0 (`ConfigPreflight` requires `ConnectionStrings:BT` and `Btdb:Security:Ldap:Server`), Secrets 0.6.2 (`${secret:btdb/connectionstrings/{bt,qa,prod}}`, `${secret:btdb/ldap/serviceaccountpassword}`; SQLite store `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`, KEK from `ZB_SECRETS_MASTER_KEY`), Auth.Ldap/AspNetCore 0.3.0 (service-account bind, `Btdb:Security:GroupToRole` → `CanonicalRole`; policies Viewer / BatchEdit (Operator+) / MachineConfig (Administrator); cookie `btdb.auth`, plain-HTTP cookie because the box has no certificate). `Microsoft.Data.SqlClient` 7.x with `Encrypt=False` forced by `BtConnectionStrings.Normalize` unless the string sets it.
|
||||
- **Behavioural deltas vs the MVC app worth knowing:** config import derives the group set from the selected machines' memberships (no separate group pick) and has a dry-run preview (`MachineConfigImportPreview.sql`: same MERGEs, `OUTPUT` into a table variable, always `ROLLBACK`); batch-edit timestamps are 7-fraction UTC strings validated on blur (no Kendo mask); the three audit reports are HTML tables with a print stylesheet instead of Telerik PDFs; `MachineAlarm` DTO property order/types were wrong in the MVC app (alarm import always threw) and are fixed against `MachineAlarmType`; every `Save*` runs in a transaction and `THROW 5001x` maps to `BtdbSaveException`.
|
||||
- **Cut-over material:** `docs/operations/cut-over.md` (parity checklist per route + THROW paths + audit-username check, secrets seed via the `secret` console + rotation, NSSM side-by-side on `:9877`, port swap to `:9876`, Overview registry entry), `deploy/` (`publish.ps1`, `install-service.bat` / `uninstall-service.bat` for NSSM service `ZB.MOM.WW.BTDB.Manager`), `tools/smoke-new-queries.sql` (runs the six never-executed queries read-only against DEV with sqlcmd). Still open after cut-over: Overview lacks the mockup's per-machine "open > 24 h" rows; Ldap `Server`/`SearchBase`/`ServiceAccountDn` are blank in `appsettings.json` by design (per-environment).
|
||||
|
||||
## 9. Local Docker rig — `docker/` (2026-09-04)
|
||||
|
||||
Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on the host (Gitea feed creds), copy into `aspnet:10.0`, never restore in Docker. Compose project `zb-btdb`, container `zb-btdb-manager`, image `zb-btdb-manager:local`, port `:9877` (same as the DEV side-by-side port, so `appsettings.json`'s Kestrel section is the single binding authority; the Dockerfile clears `ASPNETCORE_URLS`/`ASPNETCORE_HTTP_PORTS`). `ASPNETCORE_ENVIRONMENT=Production`; all environment-specific values are compose env vars, dev-only and committed under the family's rig exception.
|
||||
|
||||
- **Database:** no MSSQL of its own — joins the external `scadabridge-net` and uses ScadaBridge's `scadabridge-mssql` (the instance that already carries `ScadaBridgeConfig` + `ZbSecretsHub`). `docker/deploy-db.sh`: (1) `docker/db/BT.Clr.csproj` compiles `Database/**/*.cs` to `Database.dll` (net472 via the auto-referenced reference assemblies — builds on macOS); (2) `docker/db/BT.Local.sqlproj` (SDK-style `Microsoft.Build.Sql/2.2.0`, `EnableDefaultSqlItems=false`, globs the four `Database/` folders, Sql130 model, suppresses the legacy build's own SQL71562/71558/71502) → `BT.Local.dacpac`; (3) `db/setup.sql` — `BT` + login `btdb_app` (db_owner); (4) `db/clr-objects.sql` — `clr enabled` on the instance, `sp_add_trusted_assembly` by SHA2-512 (strict security stays on), `CREATE ASSEMBLY [Database] … SAFE` from the bytes streamed as 2000-char `0x` chunks (a single 57 KB literal gets split by sqlcmd), then `StringJoin` aggregate, `StringCompare`/`CheckTriggered`/`WhenTriggered`/`WhenCleared`/`GetAggregateValues`, `GetTSV`/`GetMachineBatchTSV`/`GetCycleTSV`/`GetLinkedBatchTSV` with SSDT's type mapping; (5) `sqlpackage /Action:Publish` from a one-shot container (`db/Dockerfile.sqlpackage`, SDK image + the dotnet tool — the host's 10.0.5 runtime could not run the tool's 10.0.11 pin) with `DropObjectsNotInSource=false`, `BlockOnPossibleDataLoss=false`, `AllowIncompatiblePlatform=true`; (6) smoke. Result on the rig: 23 tables, 37 T-SQL procs + 1 TVF, 10 CLR objects, `Database` assembly `SAFE_ACCESS`. The script diffs the legacy `<Build Include>` list against the folders and warns on drift (found: `ElmaData_Nitric.sql` + `Elma_Bulk_Query_Nitric.sql` were on disk but not in `Database.sqlproj` — added). `ScadaBridge/infra/teardown.sh`/`reseed.sh` drop the volume and BT with it; re-run `deploy-db.sh`.
|
||||
- **Why not convert `Database.sqlproj` itself:** `Microsoft.Build.Sql` does not compile SQL CLR, and the Manager depends on `dbo.StringJoin` (batch search) and `dbo.StringCompare` (machine saves). The legacy SSDT project stays the artifact of record; the two side projects reference the same files, nothing is copied.
|
||||
- **Login:** enabled, `host.docker.internal:3893` + `FallbackServers` `:3894` (glauth-redundant), bind `cn=serviceaccount,dc=zb,dc=local`; `GroupToRole` `SCADA-Admins→Administrator`, `SCADA-Designers→Operator`, `SCADA-Viewers→Viewer`. Verified: `admin`/`designer` sign in, `gw-viewer` refused (fail closed). Literal `ConnectionStrings__BT` / LDAP password env vars override the `${secret:}` refs, so the `/data/btdb-secrets.db` store stays empty and no master key is set (confirmed to boot without one). The two config-import sources point at the local BT.
|
||||
- **Health / Overview:** `/healthz` 200, `/health/ready` 200 with the `sql` entry (503 when BT is down), `/health/active` 200, `/metrics`. Compose `healthcheck` = bash `/dev/tcp` GET of `/healthz` (liveness on purpose). Registered in `ZB.MOM.WW.Overview/src/…/appsettings.Docker.json` as application **BTDB Manager**, instance `manager`, `BaseUrl http://zb-btdb-manager:9877`, `HasActiveRole false`; the containerised Overview was republished and shows the tile Up with `sql` healthy (the dashboard already sits on `scadabridge-net`, so no extra network).
|
||||
|
||||
|
||||
## 10. State as of 2026-09-11 (supersedes §1, §4, §6.1, §6.3, §6.7, §8 where they differ)
|
||||
|
||||
- **Everything is on local `master`** (`78195b8`): the Blazor conversion (§8) was merged, the
|
||||
`feat/manager-blazor` worktree is gone, and the 2026-09-04 full review
|
||||
(`docs/reviews/2026-09-04-full-review.md`, reviewed at `c624d41`) was remediated in 12 work-package
|
||||
branches — `fix/save-batch-edit-null-compare`, `fix/db-lifecycle-sprocs`, `fix/clr-openquery-escaping`,
|
||||
`fix/config-import-parents-and-nulls`, `test/db-integration`, `fix/manager-robustness`,
|
||||
`fix/auth-hardening`, `ops/runbook-and-deploy`, `perf/indexes`, `db/schema-hygiene`,
|
||||
`fix/concurrency-and-async`, `chore/nits` — all merged; 60 findings fixed, 3 partial by decision
|
||||
(DB-15 `QV_*` impersonation, DB-16 alarm-CTE consolidation, DB-21 ServiceStack table drop), 0 open.
|
||||
`feat/machine-batch-transfer` (DARS sprint 1, §1 item 1) is merged too, and so is
|
||||
`feat/transfer-machine-batch` (merged `be976a6`, 2026-09-11): **`dbo.TransferMachineBatch`**, a tenth
|
||||
Galaxy-facing writer that ends the source batch if still open, starts the destination batch by machine
|
||||
`Code`, copies the `MachineBatchWO` rows still on the source (`MoveOutTimestamp IS NULL`, narrowed to
|
||||
`@ContainerID`; blank = whole batch; source rows never modified) and records the `MachineBatchTransfer`
|
||||
link — by composing `EndMachineBatch` / `StartMachineBatch` / `AddMachineBatchTransfer` in one owned
|
||||
transaction. Outputs `@o_DestMachineBatchID`, `@o_MachineBatchTransferID`. Documented in
|
||||
`docs/BatchTracker_Reference.md`; proven by `ValidateLifecycleSprocs.sql` §F (82 PASS on the rig).
|
||||
`fix/transfer-lot-walk` (merged `5f0da01`, 2026-09-11) **reopened review DB-6**: the 2026-09-04 loop guard
|
||||
in `AddMachineBatchTransfer` walked backward (toward the batches that fed the destination), which the
|
||||
already-transferred check makes unreachable for any source, so `A→B` then `B→A` was still accepted; it
|
||||
now walks forward along source→dest links with `HOLDLOCK`, and the destination row is held like the
|
||||
source. The chain walks in `GetLinkedBatchTSV.cs` and `LinkedBatchAlarm_Wrapper.sql` picked one root
|
||||
with `TOP 1` and dropped the other branch of a lot merge (`A→C, B→C`); both now resolve the whole lot
|
||||
(forward to the final batch, then every batch that fed it) with one CTE pair pinned equal by
|
||||
`SchemaHygieneTests`. Rig proofs: `ValidateLifecycleSprocs.sql` 87/0, `docker/db/validate-clr.sql`
|
||||
27/0 over a merged lot with a `Runtime.dbo.Events` stand-in.
|
||||
`feat/manager-transfers` (merged `695192d`, 2026-09-11) gave the **Manager** the transfer side it lacked: the
|
||||
batch edit page edits the batch's outgoing `MachineBatchTransfer` row as a fourth MERGEd collection in
|
||||
`SaveBatchEdit.sql` (guards `50015` self, `50016` destination missing, `50017` loop, `50018` more than one;
|
||||
destination row and chain walk held like `AddMachineBatchTransfer`), audited into the new
|
||||
`AuditLogMachineBatchTransfer` under the batch-edit header (new TVP `MachineBatchTransferType`); a read-only
|
||||
lot panel (`GetBatchLot.sql`, the same `Down`/`Lot` CTE as the CLR, pinned equal by a static test); a lineage
|
||||
column on Find Batch; a Transfers section on the batch-edit audit page and PDF. Rig proofs: 28 integration
|
||||
tests (three new: round trip with audit, loop refused, merged lot), smoke 17/17, lifecycle 87/0, transfer
|
||||
chains 4/0, validate-clr 27/0; gate 961 tests, 0 warnings. No branch is in flight.
|
||||
- **`master` is ~198 commits ahead of `origin/master` (`d147c10`) and unpushed.** Pushing is the user's
|
||||
call — the remote is the corporate `ZimmerBiomet/BTDB`.
|
||||
- **Solution shape now:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor host) +
|
||||
`src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer, embedded `Queries/*.sql`) + five test projects
|
||||
(`Manager.Core.Tests` static query invariants, `Manager.Tests` bUnit/boot, `Database.Tests` static SQL
|
||||
+ sqlproj invariants, `Clr.Tests` compiles the CLR sources into net10, `Manager.Core.IntegrationTests`
|
||||
opt-in on the rig behind `BTDB_TEST_CONNECTION`). `BTDB.sln` keeps `Database/Database.sqlproj`
|
||||
(Windows MSBuild only). `.gitea/workflows/build.yml` runs `./build.sh` (needs `GITEA_NUGET_TOKEN`).
|
||||
- **Invariants a future session must not break** (all test-enforced): TVPs bind by ordinal so model
|
||||
property order + lengths must equal `Database/Types/*.sql`; every write query is transaction-free and
|
||||
audited (the C# wrapper owns the transaction); `dbo.X` only, never `BT.dbo.X`; `SYSUTCDATETIME()` in
|
||||
Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups;
|
||||
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
|
||||
`ExpectedLastUpdate` → `THROW 50014`.
|
||||
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
|
||||
service-account secret is gone since 2026-10-01 — see below; only `dotnet run` against local glauth uses it), store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
|
||||
master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2.
|
||||
- **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`,
|
||||
`docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo
|
||||
orientation and still loads) but never reach the corporate remote. `docs/operations/`,
|
||||
`docs/BatchTracker_Reference.md`, `docs/mockups/` stay tracked.
|
||||
- **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through
|
||||
gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and
|
||||
direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded.
|
||||
- **2026-10-01 — LDAP sign-in by user bind** (merged `a81a480`): Auth 0.4.0/0.4.1 added opt-in
|
||||
`LdapOptions.BindMode=User`; BTDB ships it. Sign-in binds as `UserBindTemplate` (`<DOMAIN>\{0}` —
|
||||
down-level maps exactly to the sAMAccountName the group search uses), the same connection reads the
|
||||
user's own `memberOf`/`displayName`, and the username returned is the directory's spelling. No service
|
||||
account, no LDAP secret; the preflight requires the template and refuses `AllowInsecure`/`Transport=None`
|
||||
with user bind outside Development. Local dev + docker rig stay `BindMode=ServiceAccount` (GLAuth bind DNs
|
||||
embed the user's OU). Rollout to the three Manager nodes (vd03 first) and retiring the stored secret are
|
||||
the open steps (plan `docs/plans/2026-10-01-ldap-user-bind.md`, gitignored).
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
||||
# ScadaBridge — index history
|
||||
|
||||
- **2026-10-13 existing fixes verified and issues closed.** Main `513fa1aa` already includes #35 fail-closed unavailable configured body/SQL redactors (`006202f3`) and #37 Flow Debugger structured/raw validated method inputs (`b5eb08f1`). Fresh full AuditLog project 407 passed/0 failed; FlowDebuggerPageTests 47 passed/0 failed; both terminal exit0. Remote ancestry confirmed; #35/#37 closed with evidence. No source changes/deployment/MES transactions. #36 runtime reset-echo overload is implemented, but QA/prod script port remains tracked; #30 per-site PSK deployment gate remains open.
|
||||
|
||||
- **2026-10-13 #36 runtime regression proof refreshed.** Rebuilt HandshakeResetEchoTests passed all three cases with local fakes, terminal exit0; an earlier no-build attempt matched no tests and is excluded from evidence. Tracker now distinguishes implemented runtime b623fdb0 from remaining QA/prod database-owned script rollout (deployment register row119, scripts13/14/36 and15/16/25/26/37/38). No database writes, deployments or fresh live rig claims.
|
||||
+4
-2
@@ -7,8 +7,10 @@ obj/
|
||||
[Aa]rtifacts/
|
||||
*.user
|
||||
|
||||
# Claude Code tooling / agent worktrees (local-only)
|
||||
.claude/
|
||||
# Claude Code tooling / agent worktrees (local-only) — but .claude/skills/ IS tracked:
|
||||
# the sister-project-history skill holds the per-project narratives the root index points at.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
# Local ops/infra notes — internal access details, never publish
|
||||
zimmer-vpn-tunnel.md
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# ZB.MOM.WW.Audit
|
||||
|
||||
Shared audit event model + writer seam for the **ZB.MOM.WW SCADA family** (single package; DEEP-adopted by all apps as the canonical record). Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/audit/`.
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Narrative
|
||||
|
||||
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`;
|
||||
23 tests (verified 2026-07-18); `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).
|
||||
@@ -16,9 +16,9 @@ Canonical audit event model, best-effort writer seam, and redactor seam for the
|
||||
|
||||
| Consumer | ZB.MOM.WW.Audit |
|
||||
|---|:---:|
|
||||
| **OtOpcUa** | yes (adoption deferred) |
|
||||
| **MxAccessGateway** | yes (adoption deferred) |
|
||||
| **ScadaBridge** | yes (adoption deferred — "align, don't replace") |
|
||||
| **OtOpcUa** | yes (adoption deferred; stale → #17) |
|
||||
| **MxAccessGateway** | yes (adoption deferred; stale → #17) |
|
||||
| **ScadaBridge** | yes (adoption deferred — "align, don't replace"; stale → #17) |
|
||||
|
||||
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".
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# ZB.MOM.WW.Auth
|
||||
|
||||
Shared login / identity / API-key library for the **ZB.MOM.WW SCADA family** (4 packages + `ApiKeys.SqlServer`; consumed by OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway). Build/test from this directory: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`). Component spec + gaps: `../components/auth/`.
|
||||
|
||||
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #5 (roll apps to `0.4.1`), #6 (VD03 NAM AD cutover), #18 (Secrets migrator jitter, ported from here). The history below is a record, not a status tracker; file new follow-ups as issues.
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Component-table row as it read in the index
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Auth (login / identity / authz) | Adopted (**feed carries `0.3.0` as of 2026-09-02 — five packages, the new one being `ZB.MOM.WW.Auth.ApiKeys.SqlServer`; ALL FOUR apps pin `0.3.0` as of 2026-09-03**: OtOpcUa `master @ b8b4c18f`, ScadaBridge `main @ e45c4af9` (the `inbound-api-key-shared-store` work, merged), MxAccessGateway `main @ 4f931e4`, HistorianGateway `main @ d8d890c` — each built + tested with no new failures versus its pre-bump baseline, all PUSHED to origin. The preceding `0.2.1` pins landed 2026-08-13 (OtOpcUa `08e33fc7`, ScadaBridge `d28824d9`, MxAccessGateway `1cb14d2`, HistorianGateway `e42ff31`). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover**, **`0.2.1` (2026-08-12) fixes a defect that broke AD logins outright** — an AD continuation referral escaped `NovellLdapConnection.Search`, so any `SearchBase` at a domain root failed every login as `ServiceAccountBindFailed` against a perfectly healthy directory. Live-proven against `DC=nam,DC=zimmer,DC=com`; the app-level exposure is closed by the pin bumps. **`FallbackServers` is live-gated** via the local redundant GLAuth pair ([`infra/glauth-redundant/`](infra/glauth-redundant/), primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
|
||||
|
||||
### Narrative
|
||||
|
||||
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
|
||||
[`current-state`](components/auth/current-state/) docs, and an adoption [`GAPS`](components/auth/GAPS.md)
|
||||
backlog. Common ground = LDAP/GLAuth identity + peppered-HMAC API keys; left per-project =
|
||||
the authz vocabularies (OPC-UA permissions / gRPC scopes / roles + site-scoping).
|
||||
|
||||
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/)
|
||||
(a plain directory of this repo — no nested `.git`, verified 2026-08-07; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
|
||||
250 tests passing + 2 skipped (verified 2026-08-10 at `0.2.0`; was 215+1 at `0.1.5`); feed carries `0.1.0`–`0.2.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).
|
||||
**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: **all four apps pin `0.3.0` as of 2026-09-03** (commits listed in the table row above, all pushed to origin); all four pinned `0.2.1` from 2026-08-13 before that. Prior history — all four pinned `0.1.5` (verified 2026-08-07): MxGateway via `e107019` 2026-07-18 (earlier bumped `0.1.2`→`0.1.4` 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit `197731a`; also required implementing the 0.1.3 `IApiKeyAdminStore.SetScopes/SetEnabled` members on a test fake), ScadaBridge `50d79ed1` + HistorianGateway `df211ee` 2026-07-18, OtOpcUa `1adaa2fc` 2026-07-20 (PR #484, family alignment from `0.1.1`). 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` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`, skipped otherwise). Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
|
||||
MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those four **plus `ApiKeys.SqlServer`** (ApiKeys not used by OtOpcUa).
|
||||
**`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). **All four Auth pkgs published to the Gitea feed at `0.1.4`** (2026-07-09). **HistorianGateway adopted the expiry surface at `0.1.4`** — `apikey create --expires` + dashboard Expires column (Task-10 done). **`0.1.5` (2026-07-18)** is `0.1.4` plus the `SQLitePCLRaw.lib.e_sqlite3 2.1.12` transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). **`0.2.0` (2026-08-10) — LDAP backup-DC sticky failover** (plan `docs/plans/2026-08-10-ldap-backup-dc-failover.md`, design `…-design.md`; **4 pkgs published to the feed, restore-verified**; family `Directory.Build.props` now `0.2.0`): one additive `LdapOptions.FallbackServers` list (`"host"` or `"host:port"`, empty default = failover off, single-endpoint behaviour byte-unchanged) makes `LdapAuthService` walk an ordered endpoint list (primary + fallbacks) on **system-side failures only** — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) is **final and never retried elsewhere** (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is **sticky** — the last endpoint that gave *any* authoritative answer (success **or** a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed `FallbackServers` entry naming the field; optional `ILogger<LdapAuthService>` emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via `AddZbLdapAuth` (`GetService`, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (`ZB_LDAP_IT=1`). **No `Abstractions` contract change beyond the new option; no `LdapAuthFailure` enum change.** App adoption (pin bump to `0.2.0` + authoring `FallbackServers`) is a follow-up.
|
||||
**`0.2.1` (2026-08-12) — AD continuation referrals no longer fail the search** (**4 pkgs published to the feed, restore-verified**; the assembly restored *from the feed* carries the fix and `0.2.0` does not — checked as a positive control, not by trusting the push). Active Directory answers **any search based at a domain root** with continuation references (to `CN=Configuration`, `DC=ForestDnsZones`, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing `LdapReferralException` from `Next()`. That escaped `NovellLdapConnection.Search`, and `LdapAuthService` maps a failed search to `ServiceAccountBindFailed`, so **every login against `SearchBase = DC=nam,DC=zimmer,DC=com` failed system-side on both DCs while the directory, the credentials and the service account were all healthy** — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new `tools/LdapProbe` (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (`ReadEntries(ILdapSearchResults)`, extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. **Non-referral faults still propagate** — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (`NovellSearchReferralTests`), **6 verified red against the pre-fix code**, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. **No `Abstractions` change; no config change.** **Consumer impact: an app on `0.1.5` cannot authenticate against an AD domain root at all**, so the pin bump is a functional fix, not housekeeping. **Closed 2026-08-13: all four apps adopted `0.2.1`** (see the table row for commits), and `FallbackServers` was live-gated the same day against the redundant local GLAuth pair (`infra/glauth-redundant/`).
|
||||
|
||||
**`0.3.0` (2026-09-02) — `ZB.MOM.WW.Auth.ApiKeys.SqlServer`, a SQL Server provider for the API-key store** (design `ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; **5 pkgs published to the feed**, `ApiKeys.SqlServer` search-verified from the ScadaBridge checkout; family `Directory.Build.props` now `0.3.0`). Motivation: `Auth.ApiKeys`' SQLite store is a per-node file, so on a two-node active/standby host (ScadaBridge central) a key minted on one node exists only there and a failover 401s every inbound caller (ScadaBridge deferred-work row 108). The new package implements `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore` over `Microsoft.Data.SqlClient` in a `zbauth` schema (v1: `schema_version`, `api_keys`, `api_key_audit`; all timestamps `DATETIMEOFFSET(7)`, tick-exact) with a concurrent-boot-safe migrator (Serializable tx, retry on 1205/2714/1913/2627 with **jittered** backoff — the Secrets replicator's flat `100*attempt` backoff exhausted five retries about once per sixty 8-way boots in the port's soak test, so the same latent defect is an open follow-up there → #18) and a `SqliteApiKeyStoreImporter` that copies an existing SQLite file into the schema insert-where-absent (idempotent, so both nodes of a pair can import their own file and the result is the union). Wiring: `services.AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)` registers the SQL Server stores + migrator **before** delegating to `AddZbApiKeyAuth`, whose `TryAdd*` registrations then yield (same displacement mechanism as Secrets 0.5.0). One `Abstractions` addition, additive: `IApiKeyStoreMigrator`, which `SqliteAuthStoreMigrator` now implements and `ApiKeyAdminCommands` accepts via a new ctor (old `SqliteAuthStoreMigrator` ctor retained — the three `0.2.1` consumers compile unchanged). Store semantics are pinned to the SQLite contract tests re-run live (mark-used skips revoked keys, delete only removes revoked keys, rotate clears last-used/revoked, list newest-first). 92 tests in the new project, 351 across the solution, 0 failed. A DBA-run copy of the DDL ships at `docs/sql/zbauth-schema.sql`.
|
||||
|
||||
**`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
|
||||
@@ -5,12 +5,12 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<!-- 0.3.0: new ZB.MOM.WW.Auth.ApiKeys.SqlServer package — a shared SQL Server API-key store
|
||||
for clustered hosts (e.g. a ScadaBridge central pair), fixing the per-node SQLite store
|
||||
going out of sync on failover. Adds the IApiKeyStoreMigrator abstraction (SQLite
|
||||
implements it unchanged). SQLite stays the default for every existing consumer; no
|
||||
consumer code change is required to move to 0.3.0. -->
|
||||
<Version>0.3.0</Version>
|
||||
<!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
|
||||
(UserBindTemplate) and read their own groups on the same connection; no service account.
|
||||
ServiceAccount stays the default, so existing consumers need no change. -->
|
||||
<!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
|
||||
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
|
||||
<Version>0.4.1</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -30,7 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|
||||
|
||||
## Versioning
|
||||
|
||||
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
|
||||
|
||||
### 0.4.1 — LDAP review fixes
|
||||
|
||||
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
|
||||
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
|
||||
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
|
||||
- No `Abstractions` contract change.
|
||||
|
||||
### 0.4.0 — LDAP sign-in by user bind (opt-in)
|
||||
|
||||
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`).
|
||||
- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
|
||||
- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
|
||||
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
|
||||
- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
|
||||
- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
|
||||
|
||||
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
|
||||
|
||||
|
||||
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
|
||||
/// <summary>How <see cref="LdapOptions"/> authenticates a sign-in.</summary>
|
||||
public enum LdapBindMode
|
||||
{
|
||||
/// <summary>Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).</summary>
|
||||
ServiceAccount,
|
||||
|
||||
/// <summary>
|
||||
/// Bind as the signing-in user (identity built from <see cref="LdapOptions.UserBindTemplate"/>);
|
||||
/// a successful bind authenticates them, and the same connection reads their own entry for groups.
|
||||
/// No service account is used.
|
||||
/// </summary>
|
||||
User,
|
||||
}
|
||||
|
||||
public sealed record LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
@@ -19,6 +33,33 @@ public sealed record LdapOptions
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Selects the authentication flow. <see cref="LdapBindMode.ServiceAccount"/> (the default) is the
|
||||
/// 0.3.0 bind-then-search-then-bind flow; <see cref="LdapBindMode.User"/> binds as the user and needs
|
||||
/// no <see cref="ServiceAccountDn"/> / <see cref="ServiceAccountPassword"/>.
|
||||
/// </summary>
|
||||
public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
|
||||
/// normalised login name. Must contain <c>{0}</c> exactly once.
|
||||
/// <para>
|
||||
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
|
||||
/// group search uses, so the account that binds is the account whose groups are read. A UPN
|
||||
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
|
||||
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
|
||||
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string UserBindTemplate { get; init; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/> only: when <see langword="true"/> (default) a login typed as
|
||||
/// <c>DOMAIN\name</c> or <c>name@suffix</c> is reduced to <c>name</c>; when <see langword="false"/>
|
||||
/// such input is refused as bad credentials without contacting the directory.
|
||||
/// </summary>
|
||||
public bool AcceptDomainQualifiedNames { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
|
||||
/// fails with a system-side error (connect/TLS, service-account bind, or search).
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
|
||||
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
|
||||
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
|
||||
/// normalised here: the returned username is canonicalised from the directory entry's
|
||||
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
|
||||
/// only the fallback when the entry has no such value.
|
||||
/// </summary>
|
||||
internal static class LdapUserName
|
||||
{
|
||||
/// <summary>
|
||||
/// Trims <paramref name="input"/> and, when <paramref name="acceptDomainQualified"/>, strips one
|
||||
/// leading <c>DOMAIN\</c> or one trailing <c>@suffix</c>. Returns <see langword="false"/> (with
|
||||
/// <paramref name="name"/> empty) for blank input, for both forms at once, for a repeated
|
||||
/// separator, for an empty side, or for any qualified form when <paramref name="acceptDomainQualified"/>
|
||||
/// is <see langword="false"/>.
|
||||
/// </summary>
|
||||
public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
|
||||
{
|
||||
name = "";
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
return false;
|
||||
|
||||
var s = input.Trim();
|
||||
var slash = s.IndexOf('\\');
|
||||
var at = s.IndexOf('@');
|
||||
|
||||
if (slash >= 0 || at >= 0)
|
||||
{
|
||||
if (!acceptDomainQualified || (slash >= 0 && at >= 0))
|
||||
return false;
|
||||
|
||||
if (slash >= 0)
|
||||
{
|
||||
if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
|
||||
return false;
|
||||
s = s[(slash + 1)..];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unlike the slash branch (whose extracted suffix is checked for emptiness by the
|
||||
// final length check below), the extracted piece here is the *prefix* — so an empty
|
||||
// domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
|
||||
if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
|
||||
return false;
|
||||
s = s[..at];
|
||||
}
|
||||
|
||||
s = s.Trim();
|
||||
}
|
||||
|
||||
if (s.Length == 0)
|
||||
return false;
|
||||
|
||||
name = s;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
|
||||
/// is one login starting at a stale endpoint and walking on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
|
||||
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LdapAuthService : ILdapAuthService
|
||||
{
|
||||
@@ -141,6 +145,21 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
// filter and the returned result (avoids two identities for one person).
|
||||
username = username.Trim();
|
||||
|
||||
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
|
||||
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
|
||||
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
|
||||
// Refused here, before any network call, so no consumer depends on its own edge check.
|
||||
if (string.IsNullOrEmpty(password))
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
|
||||
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
|
||||
// search filter share. (The returned username is canonicalised from the entry later.)
|
||||
if (_options.BindMode == LdapBindMode.User
|
||||
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
|
||||
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
|
||||
var preferred = Volatile.Read(ref _preferredIndex);
|
||||
@@ -235,74 +254,9 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
|
||||
}
|
||||
|
||||
// 6. Service-account bind so we can search for the user's DN. A bind failure
|
||||
// here is a service-account misconfiguration — DISTINCT from a user-credential
|
||||
// failure — so it maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 7. Search for the user entry by the configured username attribute. A search
|
||||
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
|
||||
// closest system-side bucket until a DirectoryUnavailable value exists.
|
||||
IReadOnlyList<LdapSearchEntry> entries;
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||
}
|
||||
|
||||
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
if (entries.Count >= 2)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
|
||||
// here is the end user's bad credentials — the directory answered, so this is
|
||||
// returned directly and never retried against another endpoint.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
|
||||
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
|
||||
// too is an answer about the user, not about the endpoint, so it ends the walk.
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
// 11. Success — and only here, with a verified password and >= 1 group.
|
||||
var displayName = ExtractDisplayName(entry, username);
|
||||
return LdapAuthResult.Success(username, displayName, groups);
|
||||
return _options.BindMode == LdapBindMode.User
|
||||
? CompleteAsUser(conn, username, password)
|
||||
: CompleteWithServiceAccount(conn, username, password);
|
||||
}
|
||||
catch (StageFailure stage)
|
||||
{
|
||||
@@ -323,6 +277,121 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
|
||||
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||
{
|
||||
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a
|
||||
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
|
||||
// maps to ServiceAccountBindFailed.
|
||||
try
|
||||
{
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
|
||||
}
|
||||
|
||||
// 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
|
||||
// Both are authoritative answers from a healthy directory, so they end the walk.
|
||||
// We never attempt a user bind against an ambiguous DN.
|
||||
var entries = SearchUser(conn, username);
|
||||
if (entries.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
|
||||
if (entries.Count >= 2)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
|
||||
|
||||
var entry = entries[0];
|
||||
|
||||
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is
|
||||
// the end user's bad credentials — the directory answered, so this is returned directly
|
||||
// and never retried against another endpoint.
|
||||
try
|
||||
{
|
||||
conn.Bind(entry.Dn, password);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
|
||||
return SucceedFromEntry(entry, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/>: the user's own bind is the authentication; the same
|
||||
/// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
|
||||
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
|
||||
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
|
||||
/// </summary>
|
||||
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
|
||||
{
|
||||
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
|
||||
try
|
||||
{
|
||||
conn.Bind(bindName, password);
|
||||
}
|
||||
catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
|
||||
}
|
||||
|
||||
// After a good bind, anything but exactly one readable entry is a failure to establish the
|
||||
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
|
||||
var entries = SearchUser(conn, username);
|
||||
if (entries.Count != 1)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
return SucceedFromEntry(entries[0], username);
|
||||
}
|
||||
|
||||
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
|
||||
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
|
||||
{
|
||||
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
|
||||
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
|
||||
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
|
||||
// Connect), until a DirectoryUnavailable value exists.
|
||||
try
|
||||
{
|
||||
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
|
||||
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
|
||||
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
|
||||
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
|
||||
/// fallback when the entry carries no such value.
|
||||
/// </summary>
|
||||
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
|
||||
{
|
||||
IReadOnlyList<string> groups;
|
||||
try
|
||||
{
|
||||
groups = ExtractGroups(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
||||
|
||||
var canonical = ExtractUserName(entry, username);
|
||||
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
|
||||
/// out of a stage to the single fail-closed catch site. Never escapes this type.
|
||||
@@ -337,21 +406,42 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
public LdapAuthFailure Failure { get; }
|
||||
|
||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
|
||||
public string Stage { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the distinct attribute list requested from the directory. The display-name
|
||||
/// and group attributes are de-duplicated so we never request the same attribute twice
|
||||
/// when an operator configures them to the same value.
|
||||
/// Builds the distinct attribute list requested from the directory: display name, groups and
|
||||
/// the username attribute (read back as the canonical username). They are de-duplicated,
|
||||
/// case-insensitively, so we never request the same attribute twice when an operator
|
||||
/// configures two of them to the same value.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> BuildSearchAttributes()
|
||||
{
|
||||
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
|
||||
return new[] { _options.DisplayNameAttribute };
|
||||
var attributes = new List<string>(3);
|
||||
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
|
||||
{
|
||||
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
|
||||
attributes.Add(name);
|
||||
}
|
||||
|
||||
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first non-blank value of the configured username attribute — the directory's
|
||||
/// canonical spelling — falling back to the (already normalised) typed username.
|
||||
/// </summary>
|
||||
private string ExtractUserName(LdapSearchEntry entry, string username)
|
||||
{
|
||||
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
|
||||
&& values.Count > 0
|
||||
&& !string.IsNullOrWhiteSpace(values[0]))
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return username;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
|
||||
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
|
||||
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
|
||||
/// search runs against);</item>
|
||||
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
|
||||
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
|
||||
/// <item>in <see cref="LdapBindMode.ServiceAccount"/> mode <see cref="LdapOptions.ServiceAccountDn"/>
|
||||
/// must be specified — an empty value would bind anonymously, defeating the search-then-bind
|
||||
/// authentication flow; in <see cref="LdapBindMode.User"/> mode <see cref="LdapOptions.UserBindTemplate"/>
|
||||
/// must contain <c>{0}</c> exactly once instead;</item>
|
||||
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
|
||||
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
|
||||
/// until the primary outage it exists to survive.</item>
|
||||
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
if (options.BindMode == LdapBindMode.User)
|
||||
{
|
||||
// User mode binds as the signing-in user, so there is no service account to require.
|
||||
// The template is the bind identity: without exactly one {0} every login would bind as
|
||||
// the same fixed name (or as a name with the user spliced in twice).
|
||||
if (CountPlaceholders(options.UserBindTemplate) != 1)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
|
||||
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
|
||||
"or \"EXAMPLE\\{0}\".");
|
||||
}
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
|
||||
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static int CountPlaceholders(string? template)
|
||||
{
|
||||
if (string.IsNullOrEmpty(template))
|
||||
return 0;
|
||||
|
||||
var count = 0;
|
||||
for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
|
||||
i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
|
||||
await host.StartAsync();
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbLdapAuth_BindsUserBindModeKeys()
|
||||
{
|
||||
IConfiguration config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
|
||||
[$"{LdapSection}:BindMode"] = "User",
|
||||
[$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
|
||||
[$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
|
||||
})
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
|
||||
services.AddZbLdapAuth(config, LdapSection);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<LdapOptions>>().Value;
|
||||
|
||||
Assert.Equal(LdapBindMode.User, options.BindMode);
|
||||
Assert.Equal("{0}@corp.test", options.UserBindTemplate);
|
||||
Assert.False(options.AcceptDomainQualifiedNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
private bool _throwOnConnect;
|
||||
private bool _throwOnServiceBind;
|
||||
private bool _throwOnUserBind;
|
||||
private int? _firstBindResultCode;
|
||||
private bool _throwOnSearch;
|
||||
|
||||
// ---- observation -----
|
||||
|
||||
@@ -32,13 +34,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
/// </summary>
|
||||
public int BindAttempts { get; private set; }
|
||||
|
||||
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
|
||||
public IReadOnlyList<string>? SearchAttributes { get; private set; }
|
||||
|
||||
/// <summary>Every call in order: <c>connect</c>, <c>bind:<dn></c>, <c>search:<filter></c>.</summary>
|
||||
public List<string> Calls { get; } = new();
|
||||
|
||||
// ---- builder methods -----
|
||||
|
||||
/// <summary>
|
||||
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
|
||||
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
|
||||
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
|
||||
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
|
||||
/// </summary>
|
||||
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
|
||||
public FakeLdapConnection WithUserEntry(
|
||||
string dn,
|
||||
string[] memberOf,
|
||||
string? displayName = null,
|
||||
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
|
||||
{
|
||||
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -46,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
};
|
||||
if (displayName is not null)
|
||||
attrs["displayName"] = new[] { displayName };
|
||||
if (extraAttributes is not null)
|
||||
{
|
||||
foreach (var (name, values) in extraAttributes)
|
||||
attrs[name] = values;
|
||||
}
|
||||
|
||||
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
|
||||
return this;
|
||||
@@ -83,6 +101,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
|
||||
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
|
||||
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
|
||||
/// </summary>
|
||||
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
|
||||
{
|
||||
_firstBindResultCode = resultCode;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
|
||||
public FakeLdapConnection ThrowOnSearch()
|
||||
{
|
||||
_throwOnSearch = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
|
||||
/// simulate an unreachable directory (infrastructure failure).
|
||||
@@ -120,6 +156,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
Calls.Add("connect");
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
ConnectCertCallback = serverCertificateValidationCallback;
|
||||
if (_throwOnConnect)
|
||||
@@ -130,8 +167,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
public void Bind(string dn, string password)
|
||||
{
|
||||
BindAttempts++;
|
||||
Calls.Add($"bind:{dn}");
|
||||
var isServiceBind = BindAttempts == 1;
|
||||
|
||||
if (isServiceBind && _firstBindResultCode is { } code)
|
||||
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
|
||||
|
||||
if ((_throwOnServiceBind && isServiceBind)
|
||||
|| (_throwOnUserBind && !isServiceBind)
|
||||
|| _throwBindDns.Contains(dn))
|
||||
@@ -147,7 +188,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
string searchBase,
|
||||
string filter,
|
||||
IReadOnlyList<string> attributes)
|
||||
=> _scriptedEntries.AsReadOnly();
|
||||
{
|
||||
Calls.Add($"search:{filter}");
|
||||
SearchAttributes = attributes;
|
||||
if (_throwOnSearch)
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
|
||||
return _scriptedEntries.AsReadOnly();
|
||||
}
|
||||
|
||||
public void Dispose() { /* nothing to clean up */ }
|
||||
}
|
||||
|
||||
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
|
||||
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("alice", r.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
|
||||
{
|
||||
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
|
||||
|
||||
Assert.True(r.Succeeded);
|
||||
Assert.Equal("ALICE", r.Username);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
|
||||
var svc = new LdapAuthService(Opts(), factory);
|
||||
|
||||
var r = await svc.AuthenticateAsync("alice", password!, default);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="LdapBindMode.User"/>: bind as the signing-in user, then read their own entry on the
|
||||
/// same connection. The service account in <see cref="Opts"/> is deliberately populated so the
|
||||
/// tests prove it is never used.
|
||||
/// </summary>
|
||||
public class LdapAuthServiceUserBindTests
|
||||
{
|
||||
private static LdapOptions Opts(params string[] fallbacks) => new()
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "dc1",
|
||||
Port = 636,
|
||||
Transport = LdapTransport.Ldaps,
|
||||
SearchBase = "dc=x",
|
||||
UserNameAttribute = "sAMAccountName",
|
||||
DisplayNameAttribute = "displayName",
|
||||
GroupAttribute = "memberOf",
|
||||
BindMode = LdapBindMode.User,
|
||||
UserBindTemplate = "{0}@corp.test",
|
||||
ServiceAccountDn = "cn=svc,dc=x",
|
||||
ServiceAccountPassword = "svcpw",
|
||||
FallbackServers = fallbacks,
|
||||
};
|
||||
|
||||
private static FakeLdapConnection Jane() => new FakeLdapConnection()
|
||||
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
|
||||
|
||||
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
|
||||
svc.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
Assert.Equal("Jane Doe", result.DisplayName);
|
||||
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
|
||||
var conn = Assert.Single(factory.Created);
|
||||
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
|
||||
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
[InlineData(" jdoe ")]
|
||||
public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("@x")]
|
||||
[InlineData(@"a\b\c")]
|
||||
[InlineData("a@b@c")]
|
||||
public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), typed);
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
|
||||
|
||||
var result = await Login(svc, @"NAM\jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Empty(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NonCredentialBindError_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConnectFailure_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchFailure_FailsOver()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryEndpointDown_IsSystemSide()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
|
||||
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
Assert.Single(factory.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoGroups_IsGroupLookupFailed()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(
|
||||
new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
|
||||
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
|
||||
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("jdoe", result.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal("JDOE", result.Username);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_RequestsTheUserNameAttribute()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
await Login(new LdapAuthService(Opts(), factory), "jdoe");
|
||||
|
||||
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
|
||||
{
|
||||
var factory = new SequenceLdapConnectionFactory(Jane());
|
||||
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
|
||||
await Login(svc, "jdoe");
|
||||
|
||||
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
|
||||
}
|
||||
}
|
||||
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
|
||||
Assert.False(new LdapOptionsValidator()
|
||||
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
|
||||
.Failed);
|
||||
|
||||
private static LdapOptions UserOpts() => Opts() with
|
||||
{
|
||||
BindMode = LdapBindMode.User,
|
||||
UserBindTemplate = "{0}@corp.test",
|
||||
ServiceAccountDn = "",
|
||||
ServiceAccountPassword = "",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Defaults_KeepServiceAccountMode()
|
||||
{
|
||||
var o = new LdapOptions();
|
||||
Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
|
||||
Assert.Equal("", o.UserBindTemplate);
|
||||
Assert.True(o.AcceptDomainQualifiedNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validator_UserMode_DoesNotRequireServiceAccount() =>
|
||||
Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
[InlineData("{0}{0}@corp.test")]
|
||||
public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
|
||||
{
|
||||
var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
||||
|
||||
public class LdapUserNameTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("jdoe")]
|
||||
[InlineData(" jdoe ")]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
public void Accepting_ReducesToBareName(string input)
|
||||
{
|
||||
Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
|
||||
Assert.Equal("jdoe", name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"NAM\jdoe")]
|
||||
[InlineData("jdoe@corp.test")]
|
||||
public void Strict_RefusesQualifiedNames(string input) =>
|
||||
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
|
||||
|
||||
[Fact]
|
||||
public void Strict_AcceptsBareName()
|
||||
{
|
||||
Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
|
||||
Assert.Equal("jdoe", name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("@x")]
|
||||
[InlineData(@"\jdoe")]
|
||||
[InlineData("jdoe@")]
|
||||
[InlineData(@"NAM\")]
|
||||
[InlineData(@"a\b\c")]
|
||||
[InlineData("a@b@c")]
|
||||
[InlineData(@"NAM\jdoe@corp.test")]
|
||||
public void Unusable_IsRefused(string? input)
|
||||
{
|
||||
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
|
||||
Assert.Equal("", name);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
|
||||
|
||||
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
|
||||
|
||||
**Built at 0.1.0. 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`.
|
||||
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
|
||||
|
||||
## Status
|
||||
|
||||
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
|
||||
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17) — per-app result is tracked in:
|
||||
|
||||
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
|
||||
|
||||
@@ -75,3 +75,33 @@ 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)
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Narrative
|
||||
|
||||
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`; 42 tests (verified 2026-07-18); `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 (stale → #17); 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).
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ZB.MOM.WW.GalaxyRepository
|
||||
|
||||
Shared Galaxy object-hierarchy SQL browse + `galaxy_repository.v1` gRPC service for the **ZB.MOM.WW SCADA family** (consumed by HistorianGateway and mxaccessgw). Build/test from this directory: `dotnet test`.
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Narrative
|
||||
|
||||
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.2.0` (originally a
|
||||
cross-repo `ProjectReference` to this scadaproj tree; switched to the feed package 2026-06-24; bumped `0.1.0` → `0.2.0` 2026-06-25).
|
||||
**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).
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central health process or network hop; probes run in-process alongside the application.
|
||||
|
||||
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #11 (re-verify per-app probe coverage vs the spec). The history below is a record, not a status tracker; file new follow-ups as issues.
|
||||
|
||||
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
|
||||
|
||||
**Built at 0.3.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
|
||||
@@ -75,3 +77,30 @@ Design documentation:
|
||||
- `~/Desktop/scadaproj/components/health/spec/SPEC.md` — normalized three-tier target
|
||||
- `~/Desktop/scadaproj/components/health/shared-contract/ZB.MOM.WW.Health.md` — proposed API (aligned to shipped code)
|
||||
- `~/Desktop/scadaproj/components/health/current-state/` — per-project current state (code-verified)
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Component-table row as it read in the index
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| Health (readiness / liveness / active-node) | **Adopted** (feed carries `0.1.0`–`0.3.0`, current lib version **`0.3.0`**; **all 4 apps** reference it + wire `MapZbHealth`). **`0.2.0` (2026-07-24)** added an optional per-entry `data` object to the canonical health JSON and made `AkkaClusterHealthCheck` publish its cluster view (`leader`/`selfAddress`/`selfRoles`/`memberCount`/`unreachableCount`) — additive, so pre-0.2.0 payloads still parse. **`0.2.1` (2026-07-24) is a behaviour fix with a live consumer impact:** the active tier now returns **Unhealthy (503)** on a role-member-but-not-leader node instead of `Degraded`, which the spec maps to 200. The old value made `/health/active` answer 200 on *every* node, so it could not distinguish active from standby over HTTP at all — and OtOpcUa's Traefik routes the admin UI by exactly this probe, so its leader-pinning had silently never worked ([`lmxopcua#494`](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/494)). **`0.3.0` (2026-07-24) finished that fix and ENDED a family-wide duplication.** 0.2.1 made the tier a real 503 but still selected by `RoleLeader`; on a rebuilt rig the two orderings diverge live (akka leader `central-1`, oldest admin `central-2` — the singleton host), so it would have pinned Traefik to the node *not* hosting the work. **Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered.** They agree on a freshly-formed cluster — which is why single-node and happy-path tests never caught it — and diverge permanently after any restart. Both Akka apps had already discovered this independently and each written a private replacement rather than use the shared check (ScadaBridge `OldestNodeActiveHealthCheck`, OtOpcUa `ClusterPrimaryHealthCheck`), so the package's entire active-node surface had **zero consumers — not unused but avoided, twice, for the same reason.** 0.3.0 promotes those two into one `ClusterActiveNode` primitive (oldest `Up` member, optional role scope, plus an ordered role-preference so a fused node answers for the role its singletons are pinned to); `ActiveNodeHealthCheck` and `AkkaActiveNodeGate` both delegate to it, and results now carry `activeRole`/`selfAddress`/`activeNode` in the 0.2.0 `data` object so a **standby reports who IS active**. Two further behaviour changes: a node holding **none** of the preferred roles is now **Unhealthy**, not Healthy — the old "not applicable ⇒ Healthy" is exactly what made the tier answer 200 everywhere, and the case is reachable (OtOpcUa's `RoleParser` admits `dev`-only and `cluster-`only nodes); and identity compares `UniqueAddress`, so a node restarted on the same host:port is correctly a different member. **Both apps migrated and their private copies deleted** — OtOpcUa (`feat/health-0.3.0-active-node` @ `88adec04`) also routes `RedundancyStateActor.SelectOldestUpMemberOfRole` through the shared primitive, so the tier and the OPC UA `ServiceLevel` 250/240 split cannot drift; ScadaBridge (`feat/site-node-health` @ `5d4853a1`) routes `ActiveNodeEvaluator` through it, unifying the S&F delivery gate, the heartbeat `IsActive` stamp, the inbound-API gate and the health tier on one rule. ScadaBridge keeps `SitePairActiveNodeHealthCheck` deliberately — it is a thin adapter over the site's own role-scoped `IClusterNodeProvider` (itself now backed by the shared rule), not a second copy of it. mxgw/HistorianGateway have no Akka cluster and are unaffected. **Both app branches are MERGED and PUSHED** (verified 2026-08-07): OtOpcUa `88adec04` is on `origin/master`; ScadaBridge merged via `b3dc17a5` (2026-07-24) on `origin/main`, private-copy deletions included. | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
|
||||
|
||||
### Narrative
|
||||
|
||||
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`;
|
||||
64 tests (verified 2026-07-18); `dotnet pack` → 3 nupkgs @ 0.1.0).
|
||||
**Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified (→ #11) — see [`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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
# ZB.MOM.WW.Overview
|
||||
|
||||
The hosted family dashboard (Blazor Server). Build/test from this directory: `dotnet build ZB.MOM.WW.Overview.slnx`, `dotnet test ZB.MOM.WW.Overview.slnx` (fully offline).
|
||||
|
||||
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #14 (re-run the two OtOpcUa-blocked Phase 4 acceptance checks). The history below is a record, not a status tracker; file new follow-ups as issues.
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
|
||||
Besides the shared libraries, this repo hosts one **application**:
|
||||
[`ZB.MOM.WW.Overview/`](ZB.MOM.WW.Overview/) — a Blazor Server single-pane dashboard that polls
|
||||
every registered instance's anonymous `/health/ready` + `/health/active`, caches one snapshot
|
||||
(pages never poll) and renders per-cluster state including the Akka **leader chip**, which is what
|
||||
the `ZB.MOM.WW.Health` `0.2.0`/`0.3.0` per-entry `data` work exists to feed. **No login, strictly
|
||||
read-only, holds no credentials** — a requirement, not an omission; it references no Auth/Audit/
|
||||
Secrets package. Design + impl plan: `docs/plans/2026-07-22-overview-dashboard-{design,impl-plan}.md`
|
||||
(ledger 19/20; the open task is Phase 4 live acceptance, recorded in `41013dc` as 7 of 9 design §9
|
||||
checks passing, 2 blocked on an OtOpcUa rig redeploy → #14).
|
||||
|
||||
The `Applications` registry ships **empty** on purpose: a deployment supplying none fails startup
|
||||
naming the key rather than booting an empty dashboard. Per-deployment overlays are committed —
|
||||
`appsettings.Docker.json` (the local rigs, joined via external networks) and
|
||||
`appsettings.Wonder.json`.
|
||||
|
||||
**Deployed to `wonder-app-vd03` 2026-08-11** (`main @ 4f97e72`) as that box's **fifth** app:
|
||||
service `ZbOverview` on **:5320** under the shared NSSM, `ASPNETCORE_ENVIRONMENT=Wonder`,
|
||||
5/5 instances Up. Two constraints worth carrying to any future box:
|
||||
|
||||
- **Publish self-contained** (`-r win-x64 --self-contained true`). wonder has **no .NET 10 runtime**
|
||||
— every app there carries its own. An FDD publish installs fine and then fails at service start.
|
||||
- **Registry `BaseUrl` must not put co-located instances on a shared host name.** Leader-address →
|
||||
instance resolution matches on **host only** (Akka and HTTP ports differ by design), which assumes
|
||||
distinct hosts per instance — true on the rig, false on a single box, where the fleet-wide scan
|
||||
lets the first matching entry win the chip for *every* group. On wonder that made ScadaBridge's
|
||||
**site** card claim `Leader · central`, a different Akka cluster. Fixed by using `127.0.0.1`,
|
||||
which matches nothing, so each chip falls back to the raw Akka address.
|
||||
|
||||
**QA runs an instance on EACH pair node — `wonder-app-vq03:5320` and (since 2026-08-14)
|
||||
`wonder-app-vq04:5320`** — both watching the same registry, now **10 instances across 4
|
||||
applications** after the 2026-08-14 VQ04 singleton clone (MxGateway + HistorianGateway added on
|
||||
vq04; see `deployment.md` §7.1). It is the fastest way to read QA's whole topology.
|
||||
Its registry uses **real FQDNs**, and that is correct rather than a divergence: the `127.0.0.1`
|
||||
rule above exists only because VD03 co-locates every instance on one host. QA is a **2-node pair**
|
||||
(`vq03`/`vq04`) with genuinely distinct hosts, so leader resolution works as designed and the chips
|
||||
resolve to real names (`node-a (vq03)`). Carry the *reason*, not the literal `127.0.0.1`.
|
||||
|
||||
## Commands (from the index)
|
||||
|
||||
```bash
|
||||
# ZB.MOM.WW.Overview (~/Desktop/scadaproj/ZB.MOM.WW.Overview — the hosted family dashboard)
|
||||
dotnet build ZB.MOM.WW.Overview.slnx
|
||||
dotnet test ZB.MOM.WW.Overview.slnx # 177 tests, fully offline
|
||||
cd docker && docker compose up -d --build # local rigs → http://localhost:5320 (start the rigs first)
|
||||
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
|
||||
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
|
||||
```
|
||||
|
||||
**2026-10-01 — BTDB Manager registered on all three dashboards.** `appsettings.Wonder.json` (vd03,
|
||||
committed) gains one instance `wonder` → `http://127.0.0.1:9876` (management link
|
||||
`http://10.220.157.247:9876/`); the QA overlay `E:\ApiInstall\Overview\app\appsettings.QA.json` (deployed
|
||||
only, identical on vq03/vq04) gains `vq03`/`vq04` → `http://wonder-app-vq0N.zmr.zimmer.com:9876`. All
|
||||
`HasActiveRole: false` (single unclustered Blazor app); `/health/ready` carries one `sql` entry (BT
|
||||
reachable). The registry is read at startup only, so `ZbOverview` was restarted on each node (previous
|
||||
files kept as `*.bak-20261001T02xxxx`). Verified: vd03 6/6 Up, vq03 and vq04 12/12 Up.
|
||||
|
||||
@@ -25,11 +25,14 @@ The dev rigs publish only some node ports to the host:
|
||||
| OtOpcUa central-1/2 | container `:9000` | **no** — only Traefik `:9200`, load-balanced across the pair |
|
||||
| OtOpcUa site-\* (4) | container `:8080` | **no** — not published |
|
||||
| HistorianGateway | container `:5220` | yes — `localhost:5220` |
|
||||
| BTDB Manager | container `:9877` | yes — `localhost:9877` |
|
||||
| MxAccessGateway | windev `:5130` | yes, with the VPN up |
|
||||
|
||||
So a host-run dashboard can only see part of the fleet. This stack joins the three rig networks
|
||||
(`otopcua-dev_default`, `scadabridge-net`, `zb-historiangw_default`, all declared `external`) and
|
||||
addresses every node by container DNS name, which is how it sees all of them.
|
||||
addresses every node by container DNS name, which is how it sees all of them. BTDB Manager
|
||||
(`~/Desktop/BTDB/docker`) sits on `scadabridge-net` too, because its BT database lives on
|
||||
ScadaBridge's infra MSSQL container, so no fourth network is needed for it.
|
||||
|
||||
Ports above were established by probing the running rigs on 2026-07-24, **not** read off the
|
||||
compose files — the two disagree. In particular OtOpcUa's site nodes serve health on `:8080`, not
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
// LocalDb sync re-bind the primary HTTP port)
|
||||
// ScadaBridge central :5000
|
||||
// ScadaBridge site :8084 (MetricsPort; MapZbHealth lands here from the site-health work)
|
||||
// BTDB Manager :9877 (BTDB/docker; single instance, joins scadabridge-net for BT;
|
||||
// /health/ready is 503 whenever that database is unreachable)
|
||||
//
|
||||
// ManagementUrl values stay host-relative (localhost:*) on purpose: they are followed by the
|
||||
// OPERATOR'S BROWSER, not by this container, so container DNS names would be dead links.
|
||||
@@ -146,6 +148,18 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "BTDB Manager",
|
||||
"ManagementLabel": "Manager",
|
||||
"Instances": [
|
||||
{
|
||||
"Name": "manager",
|
||||
"BaseUrl": "http://zb-btdb-manager:9877",
|
||||
"ManagementUrl": "http://localhost:9877/",
|
||||
"HasActiveRole": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "MxAccessGateway",
|
||||
"ManagementLabel": "Dashboard",
|
||||
|
||||
@@ -150,6 +150,21 @@
|
||||
"TimeoutSeconds": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "BTDB Manager",
|
||||
"ManagementLabel": "Manager",
|
||||
"Instances": [
|
||||
{
|
||||
// HasActiveRole false: a single, unclustered Blazor app — no active/standby role.
|
||||
// /health/ready carries one "sql" entry (BT on the DEV SQL box reachable) and is 503
|
||||
// whenever that database is not; /health/active is an empty check set.
|
||||
"Name": "wonder",
|
||||
"BaseUrl": "http://127.0.0.1:9876",
|
||||
"ManagementUrl": "http://10.220.157.247:9876/",
|
||||
"HasActiveRole": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,27 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr
|
||||
package in the family (see `Directory.Build.props`) even when a given release only touches
|
||||
one of them.
|
||||
|
||||
## 0.6.3 — 2026-09-28
|
||||
|
||||
### Added
|
||||
|
||||
- **gRPC hub follower sweeps are observable.** `GrpcSecretSyncService` (package
|
||||
`ZB.MOM.WW.Secrets.Replicator.Grpc`) now logs every sweep outcome instead of only the sweeps
|
||||
that pull a change:
|
||||
- `EventId 4` (Debug) — a sweep found nothing new; logged on every in-sync sweep.
|
||||
- `EventId 5` (Information) — an in-sync heartbeat, at most once per
|
||||
`SecretsGrpcHubClientOptions.InSyncLogInterval` (new, default 1 hour, validated positive), so
|
||||
"the loop is alive and still agrees with the hub" is visible at the default log level without
|
||||
a line every 30 seconds.
|
||||
- `EventId 6` (Information) — the first successful sweep after one or more failures, naming
|
||||
the failure count.
|
||||
- **`ISecretsHubSyncStatus`** — a public, DI-registered read model of the follower loop:
|
||||
`LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError`. Getters are synchronized
|
||||
with the writer, so a health check or status page reads a consistent snapshot.
|
||||
|
||||
No behaviour change to syncing itself, and no wire or schema change. Only hub followers gain
|
||||
anything from this release; other consumers can stay on 0.6.2.
|
||||
|
||||
## 0.6.2 — 2026-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.6.2</Version>
|
||||
<Version>0.6.3</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
+9
@@ -150,6 +150,15 @@ public static class SecretsGrpcHubClientExtensions
|
||||
});
|
||||
}
|
||||
|
||||
// Singleton, and registered for BOTH the concrete type and the public interface: the
|
||||
// service needs the concrete type (it calls the internal RecordSuccess/RecordFailure), and
|
||||
// an application observing hub-sync health (a readiness check, a status page) needs only
|
||||
// the read-only ISecretsHubSyncStatus. TryAddSingleton for the concrete type means the
|
||||
// interface registration below resolves the SAME instance rather than constructing a second
|
||||
// one, which would silently split "what the service records" from "what a consumer reads".
|
||||
services.TryAddSingleton<SecretsHubSyncStatus>();
|
||||
services.TryAddSingleton<ISecretsHubSyncStatus>(sp => sp.GetRequiredService<SecretsHubSyncStatus>());
|
||||
|
||||
services.AddHostedService<GrpcSecretSyncService>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -36,6 +36,13 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
private readonly SecretsGrpcHubClientOptions _options;
|
||||
private readonly ILogger<GrpcSecretSyncService> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SecretsHubSyncStatus _status;
|
||||
|
||||
// Last time a row actually moved, and last time the in-sync heartbeat was logged — both
|
||||
// instance state because they describe THIS service's observed history, not anything the hub
|
||||
// or the local store can answer on demand.
|
||||
private DateTimeOffset? _lastPullUtc;
|
||||
private DateTimeOffset? _lastInSyncHeartbeatUtc;
|
||||
|
||||
/// <summary>Creates the sync service.</summary>
|
||||
/// <param name="local">The node's local store — the only thing this service writes to.</param>
|
||||
@@ -44,13 +51,20 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
/// <param name="options">Hub-client options.</param>
|
||||
/// <param name="logger">Logger for sweep outcomes and failures.</param>
|
||||
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
|
||||
/// <param name="status">
|
||||
/// The sweep-outcome tracker this service updates. Optional so existing callers that construct
|
||||
/// this type directly (tests, mainly) keep compiling; a real host resolves the same singleton
|
||||
/// <c>AddZbSecretsGrpcHubClient</c> registers for <see cref="ISecretsHubSyncStatus"/>, so the two
|
||||
/// stay the same instance.
|
||||
/// </param>
|
||||
public GrpcSecretSyncService(
|
||||
ISecretStore local,
|
||||
ISecretsHubReader hub,
|
||||
ISecretCacheInvalidator? cacheInvalidator,
|
||||
IOptions<SecretsGrpcHubClientOptions> options,
|
||||
ILogger<GrpcSecretSyncService> logger,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
SecretsHubSyncStatus? status = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
@@ -59,6 +73,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
_options = options.Value;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_status = status ?? new SecretsHubSyncStatus();
|
||||
_reconciler = new SecretReplicationReconciler(
|
||||
local,
|
||||
cacheInvalidator,
|
||||
@@ -82,11 +97,22 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one pull-only reconcile against the hub.
|
||||
/// Runs one pull-only reconcile against the hub. Throws on any failure — an unreachable hub, a
|
||||
/// rejected credential, a deadline — so a direct caller sees the raw outcome. The background
|
||||
/// loop never calls this directly; it goes through <see cref="SweepAndRecordAsync"/>, which
|
||||
/// shares this method's reconcile core but also records the outcome and logs it.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
/// <returns>How many rows were pulled from the hub.</returns>
|
||||
internal async Task<int> SweepAsync(CancellationToken ct)
|
||||
internal async Task<int> SweepAsync(CancellationToken ct) =>
|
||||
(await SweepCoreAsync(ct).ConfigureAwait(false)).Pulled;
|
||||
|
||||
/// <summary>
|
||||
/// The reconcile core shared by <see cref="SweepAsync"/> and <see cref="SweepAndRecordAsync"/>:
|
||||
/// fetches the hub manifest and reconciles the local store against it. Throws on failure —
|
||||
/// callers that want failures recorded/logged instead do that around this call, not inside it.
|
||||
/// </summary>
|
||||
private async Task<(int Pulled, int ManifestCount)> SweepCoreAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<SecretManifestEntry> hubManifest =
|
||||
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
|
||||
@@ -97,20 +123,54 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
|
||||
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
|
||||
// 4 MB default message cap and then fail identically on every interval forever.
|
||||
return await _reconciler
|
||||
int pulled = await _reconciler
|
||||
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return (pulled, hubManifest.Count);
|
||||
}
|
||||
|
||||
private async Task SweepSafelyAsync(CancellationToken ct)
|
||||
private Task SweepSafelyAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Test-only entry point for the same outcome-recording sweep the background loop runs — never
|
||||
/// throws, always updates <see cref="ISecretsHubSyncStatus"/>, and logs exactly as a real tick
|
||||
/// would. Exists so a test can drive one sweep at a time without standing up the
|
||||
/// <see cref="BackgroundService"/> host lifecycle.
|
||||
/// </summary>
|
||||
/// <param name="ct">A token to cancel the sweep.</param>
|
||||
internal Task SweepForTestAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
|
||||
|
||||
private async Task SweepAndRecordAsync(CancellationToken ct)
|
||||
{
|
||||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
int pulled = await SweepAsync(ct).ConfigureAwait(false);
|
||||
(int pulled, int manifestCount) = await SweepCoreAsync(ct).ConfigureAwait(false);
|
||||
|
||||
int priorFailures = _status.RecordSuccess(now);
|
||||
|
||||
if (priorFailures > 0)
|
||||
{
|
||||
LogSweepRecovered(priorFailures);
|
||||
}
|
||||
|
||||
if (pulled > 0)
|
||||
{
|
||||
LogSweepConverged(pulled);
|
||||
_lastPullUtc = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogSweepInSync(manifestCount);
|
||||
|
||||
if (_lastInSyncHeartbeatUtc is null ||
|
||||
now - _lastInSyncHeartbeatUtc.Value >= _options.InSyncLogInterval)
|
||||
{
|
||||
LogSweepInSyncHeartbeat(manifestCount, _lastPullUtc);
|
||||
_lastInSyncHeartbeatUtc = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
@@ -124,6 +184,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
// has the same correct response: keep serving the local store and try again. Letting any
|
||||
// of it escape would fault the BackgroundService and take the host down over a transient
|
||||
// central outage, which is precisely the availability the pull-only topology exists for.
|
||||
_status.RecordFailure(now, ex);
|
||||
LogSweepFailed(ex);
|
||||
}
|
||||
}
|
||||
@@ -145,4 +206,36 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
|
||||
private partial void LogRowDiscarded(Exception exception, string secretName);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Debug on every sweep that found nothing to pull — the full-cadence signal, for a
|
||||
/// verbose log level, that the loop is alive and agrees with the hub even between the rare
|
||||
/// Information-level heartbeats.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 4,
|
||||
Level = LogLevel.Debug,
|
||||
Message = "Secrets hub sweep found nothing new; {Count} secret(s) already in sync.")]
|
||||
private partial void LogSweepInSync(int count);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information at most once per <see cref="SecretsGrpcHubClientOptions.InSyncLogInterval"/>
|
||||
/// — an in-sync sweep is otherwise Debug-only, so this is the periodic proof at the default log
|
||||
/// level that the follower is still converged rather than silently stuck.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 5,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}")]
|
||||
private partial void LogSweepInSyncHeartbeat(int count, DateTimeOffset? lastPullUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Logged at Information the first time a sweep succeeds after one or more failures — the
|
||||
/// recovery line that closes out whatever run of <see cref="LogSweepFailed"/> Warnings preceded it.
|
||||
/// </summary>
|
||||
[LoggerMessage(
|
||||
EventId = 6,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Secrets hub sync recovered after {Failures} failed sweep(s)")]
|
||||
private partial void LogSweepRecovered(int failures);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
|
||||
|
||||
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
|
||||
/// <remarks>
|
||||
/// Registered as a singleton by <c>AddZbSecretsGrpcHubClient</c> alongside the background sweep
|
||||
/// service, so a host can surface hub-sync health (a readiness check, a status page) without
|
||||
/// scraping logs. Every field reflects the outcome of the most recently completed sweep only —
|
||||
/// there is deliberately no history here, just "what happened last time".
|
||||
/// </remarks>
|
||||
public interface ISecretsHubSyncStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// When the most recent sweep finished, whether it succeeded or failed. <see langword="null"/>
|
||||
/// before the first sweep has completed.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSweepUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// When the most recent <em>successful</em> sweep finished. <see langword="null"/> if no sweep
|
||||
/// has ever succeeded.
|
||||
/// </summary>
|
||||
DateTimeOffset? LastSuccessUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of sweeps that have failed in a row, ending at the most recent sweep. Reset to
|
||||
/// zero the moment a sweep succeeds.
|
||||
/// </summary>
|
||||
int ConsecutiveFailures { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The message from the most recent sweep failure, or <see langword="null"/> if the most recent
|
||||
/// sweep succeeded (or none has run yet). This is the exception's type and message only — never
|
||||
/// a stack trace, and never secret material, since the sweep never has plaintext in scope.
|
||||
/// </summary>
|
||||
string? LastError { get; }
|
||||
}
|
||||
|
||||
/// <summary>The mutable implementation the sync service writes to; consumers see only the interface.</summary>
|
||||
/// <remarks>
|
||||
/// Writes (<see cref="RecordSuccess"/>/<see cref="RecordFailure"/>) run under <see cref="_gate"/>,
|
||||
/// and every getter takes the same lock rather than reading its backing field bare — a plain field
|
||||
/// read races the writer with no synchronization otherwise, and a torn/half-written
|
||||
/// <see cref="DateTimeOffset"/> (a multi-field struct) or an interleaving that pairs one write's
|
||||
/// <c>LastSweepUtc</c> with another write's <c>LastError</c> is exactly the inconsistency a status
|
||||
/// consumer must not see.
|
||||
/// </remarks>
|
||||
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
|
||||
private DateTimeOffset? _lastSweepUtc;
|
||||
private DateTimeOffset? _lastSuccessUtc;
|
||||
private int _consecutiveFailures;
|
||||
private string? _lastError;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSweepUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastSweepUtc; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTimeOffset? LastSuccessUtc
|
||||
{
|
||||
get { lock (_gate) { return _lastSuccessUtc; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int ConsecutiveFailures
|
||||
{
|
||||
get { lock (_gate) { return _consecutiveFailures; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? LastError
|
||||
{
|
||||
get { lock (_gate) { return _lastError; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful sweep, resetting the failure streak.
|
||||
/// </summary>
|
||||
/// <param name="now">The instant the sweep completed.</param>
|
||||
/// <returns>
|
||||
/// The consecutive-failure count <em>before</em> this success — the caller uses a non-zero
|
||||
/// result to decide whether the sweep is a recovery worth logging.
|
||||
/// </returns>
|
||||
internal int RecordSuccess(DateTimeOffset now)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
int prior = _consecutiveFailures;
|
||||
_lastSweepUtc = _lastSuccessUtc = now;
|
||||
_consecutiveFailures = 0;
|
||||
_lastError = null;
|
||||
return prior;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records a failed sweep, extending the failure streak.</summary>
|
||||
/// <param name="now">The instant the sweep failed.</param>
|
||||
/// <param name="ex">The exception the sweep failed with.</param>
|
||||
internal void RecordFailure(DateTimeOffset now, Exception ex)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lastSweepUtc = now;
|
||||
_consecutiveFailures++;
|
||||
_lastError = ex.GetType().Name + ": " + ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -77,6 +77,20 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level
|
||||
/// heartbeat. Defaults to one hour. Must be positive.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A converged follower sweeps every <see cref="SyncInterval"/> — 30 seconds by default — and
|
||||
/// finds nothing new almost every time. Logging that outcome at Information on every sweep
|
||||
/// would drown the log; staying silent forever would make "the sweep loop is alive and the hub
|
||||
/// still agrees with this node" unobservable between the rare sweeps that actually pull
|
||||
/// something. Every sweep still logs at Debug regardless of this interval, so a verbose log
|
||||
/// level shows the true cadence when it is needed.
|
||||
/// </remarks>
|
||||
public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>
|
||||
/// Throws if the options are unusable. Called by the DI extension at registration time so a
|
||||
/// misconfigured node fails at startup rather than on the first silent sweep failure.
|
||||
@@ -161,6 +175,13 @@ public sealed class SecretsGrpcHubClientOptions
|
||||
$"gRPC secrets hub client CallDeadline must be positive " +
|
||||
$"({sectionPath}:CallDeadline, was {CallDeadline}).");
|
||||
}
|
||||
|
||||
if (InSyncLogInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"gRPC secrets hub client InSyncLogInterval must be positive " +
|
||||
$"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval}).");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Secrets.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information-
|
||||
/// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the
|
||||
/// first success after a run of failures logs (EventId 6), and <see cref="ISecretsHubSyncStatus"/>
|
||||
/// tracking the same outcomes.
|
||||
/// </summary>
|
||||
public sealed class GrpcSecretSyncServiceLoggingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
|
||||
{
|
||||
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create(
|
||||
time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(30));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
time.Advance(TimeSpan.FromMinutes(31));
|
||||
await svc.SweepForTestAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
|
||||
Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 2);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Equal(2, status.ConsecutiveFailures);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, failFirst: 1);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(1, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastError);
|
||||
Assert.Null(status.LastSuccessUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_in_sync_sweep_never_logs_the_recovered_line()
|
||||
{
|
||||
var time = new FakeTimeProvider();
|
||||
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
|
||||
Harness.Create(time, manifestUnchanged: true);
|
||||
|
||||
await svc.SweepForTestAsync(default);
|
||||
await svc.SweepForTestAsync(default);
|
||||
|
||||
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
|
||||
Assert.Equal(0, status.ConsecutiveFailures);
|
||||
Assert.NotNull(status.LastSweepUtc);
|
||||
}
|
||||
|
||||
// Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the
|
||||
// scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull
|
||||
// set is always empty), a log-capturing logger, and the internal status tracker the service
|
||||
// writes to — so a test can both drive sweeps and observe everything they produced.
|
||||
private static class Harness
|
||||
{
|
||||
public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create(
|
||||
FakeTimeProvider time,
|
||||
TimeSpan? inSyncLogInterval = null,
|
||||
bool manifestUnchanged = false,
|
||||
int failFirst = 0)
|
||||
{
|
||||
var logger = new RecordingListLogger();
|
||||
var status = new SecretsHubSyncStatus();
|
||||
|
||||
ISecretsHubReader hub = failFirst > 0
|
||||
? new SequencedFailThenEmptyHubReader(failFirst)
|
||||
: new EmptyHubReader();
|
||||
|
||||
IOptions<SecretsGrpcHubClientOptions> options = Options.Create(new SecretsGrpcHubClientOptions
|
||||
{
|
||||
Endpoint = "https://central:5001",
|
||||
BearerToken = "unused-by-the-fake",
|
||||
SyncInterval = TimeSpan.FromHours(1),
|
||||
InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1),
|
||||
});
|
||||
|
||||
// manifestUnchanged has no separate fake to select: both readers above always answer an
|
||||
// empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so
|
||||
// the parameter exists to make each call site self-documenting rather than to branch on.
|
||||
var svc = new GrpcSecretSyncService(
|
||||
new NoOpLocalStore(),
|
||||
hub,
|
||||
cacheInvalidator: null,
|
||||
options,
|
||||
logger,
|
||||
time,
|
||||
status);
|
||||
|
||||
return (svc, logger, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A hub with nothing in it — every sweep is in-sync with zero rows to pull.</summary>
|
||||
private sealed class EmptyHubReader : ISecretsHubReader
|
||||
{
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails the manifest call with a transport-shaped exception for the first
|
||||
/// <paramref name="failuresBeforeSuccess"/> calls, then behaves exactly like
|
||||
/// <see cref="EmptyHubReader"/> forever after.
|
||||
/// </summary>
|
||||
private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader
|
||||
{
|
||||
private int _remaining = failuresBeforeSuccess;
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remaining > 0)
|
||||
{
|
||||
_remaining--;
|
||||
return Task.FromException<IReadOnlyList<SecretManifestEntry>>(
|
||||
new InvalidOperationException("hub unreachable"));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
|
||||
IReadOnlyList<SecretName> names, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A local store that is never actually exercised by these scenarios (the hub manifest is
|
||||
/// always empty, so the reconciler's pull set is always empty and none of these members are
|
||||
/// called) beyond <see cref="GetManifestAsync"/>, which the reconciler always calls to compute
|
||||
/// the pull set.
|
||||
/// </summary>
|
||||
private sealed class NoOpLocalStore : ISecretStore
|
||||
{
|
||||
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
|
||||
|
||||
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
|
||||
public Task<bool> ApplyRewrapAsync(
|
||||
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
|
||||
throw new NotSupportedException("not exercised by the logging test scenarios.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour.</summary>
|
||||
/// <remarks>
|
||||
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, matching
|
||||
/// this project's existing convention (see <c>HubFakeTimeProvider</c> in
|
||||
/// <c>SecretsHubAuthInterceptorTests.cs</c>) of not taking that package dependency for one small type.
|
||||
/// </remarks>
|
||||
internal sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
/// <summary>Creates a clock starting at the current wall-clock time.</summary>
|
||||
public FakeTimeProvider() : this(DateTimeOffset.UtcNow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Creates a clock starting at <paramref name="now"/>.</summary>
|
||||
/// <param name="now">The instant the clock starts at.</param>
|
||||
public FakeTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
|
||||
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
|
||||
/// <param name="delta">The amount of time to advance.</param>
|
||||
public void Advance(TimeSpan delta) => _now += delta;
|
||||
}
|
||||
|
||||
/// <summary>Captures every log entry's level, event id, and formatted message, across all levels.</summary>
|
||||
/// <remarks>
|
||||
/// Distinct from the existing <c>RecordingLogger<T></c> in <c>TestDoubles.cs</c>, which only
|
||||
/// captures Information/Warning and neither type captures <see cref="EventId"/> — this suite needs
|
||||
/// Debug-level entries and event-id assertions that the existing double does not support, and
|
||||
/// widening it would risk the tests that already depend on its narrower shape.
|
||||
/// </remarks>
|
||||
internal sealed class RecordingListLogger : ILogger<GrpcSecretSyncService>
|
||||
{
|
||||
/// <summary>Every entry logged, in order.</summary>
|
||||
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(formatter);
|
||||
|
||||
Entries.Add((logLevel, eventId, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -291,11 +291,16 @@ public sealed class GrpcSecretSyncServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_quiet_sweep_logs_nothing_at_information()
|
||||
public async Task A_quiet_sweep_logs_the_in_sync_heartbeat_once_but_no_warning()
|
||||
{
|
||||
// A quiet (nothing-to-pull) sweep no longer logs NOTHING at Information: the first-ever
|
||||
// in-sync sweep always logs the once-per-InSyncLogInterval heartbeat (EventId 5), because
|
||||
// "never logged before" is one of the two conditions that fires it. See
|
||||
// GrpcSecretSyncServiceLoggingTests for the interval-gated repeat behaviour on later sweeps.
|
||||
// What a quiet sweep still never does is warn.
|
||||
await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0);
|
||||
|
||||
Assert.Equal(0, _logger.InformationCount);
|
||||
Assert.Equal(1, _logger.InformationCount);
|
||||
Assert.Equal(0, _logger.WarningCount);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Observability libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central telemetry process; instrumentation runs in-process alongside the application.
|
||||
|
||||
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #12 (`AddZbSerilog` min-level governance hook), #13 (OTel collector / OTLP end to end), #17 (stale README text). The history below is a record, not a status tracker; file new follow-ups as issues.
|
||||
|
||||
The library normalizes the three-project observability surface: a shared OpenTelemetry Resource driven by a single identity triple (`service.name` / `site.id` / `node.role`), standard instrumentation wiring, Prometheus and OTLP export, and a Serilog bootstrap with enrichers and `TraceContextEnricher` for trace↔log correlation.
|
||||
|
||||
**Built at 0.1.0, published to the Gitea NuGet feed, and adopted across all three apps on 2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done beforehand despite the earlier claim. ScadaBridge keeps its `LoggerConfigurationFactory` (min-level governance) and only adds the shared `TraceContextEnricher`; it does not call `AddZbSerilog`. Per-repo result + deferred follow-ons tracked in `~/Desktop/scadaproj/components/observability/GAPS.md`.
|
||||
@@ -65,7 +67,7 @@ Built at **0.1.0**, published to the Gitea NuGet feed, and **adopted across all
|
||||
2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's
|
||||
MEL→Serilog migration and metrics export both landed in this pass (not beforehand, despite the
|
||||
earlier claim). Deferred follow-ons (MxGateway `ms`→`s` + Meter rename, ScadaBridge app instruments
|
||||
+ Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog:
|
||||
+ Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog (remaining open items are Gitea issues: #12 min-level governance hook, #13 OTLP collector):
|
||||
|
||||
- `~/Desktop/scadaproj/components/observability/GAPS.md` — adoption status + deferred follow-ons
|
||||
|
||||
@@ -75,3 +77,32 @@ Design documentation:
|
||||
- `~/Desktop/scadaproj/components/observability/spec/METRIC-CONVENTIONS.md` — metric naming reference
|
||||
- `~/Desktop/scadaproj/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md` — proposed shared-library API
|
||||
- `~/Desktop/scadaproj/components/observability/current-state/` — per-project current state (code-verified)
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Narrative
|
||||
|
||||
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`; 34 tests (verified 2026-07-18);
|
||||
`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).
|
||||
|
||||
@@ -154,7 +154,7 @@ lockstep from `Directory.Build.props`.
|
||||
## Status
|
||||
|
||||
**Built at 0.1.0. MxAccessGateway logging adopted (MEL → Serilog migration, on its own branch).
|
||||
Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** Adoption is tracked in the
|
||||
Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** (stale → #17) Adoption is tracked in the
|
||||
component backlog:
|
||||
|
||||
- `~/Desktop/scadaproj/components/observability/GAPS.md`
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# ZB.MOM.WW.Theme
|
||||
|
||||
Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`.
|
||||
|
||||
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #9 (roll `0.5.0` to the other consumers), #10 (`layout.css` `calc(100vh - 3.3rem)` review). The history below is a record, not a status tracker; file new follow-ups as issues.
|
||||
|
||||
## Published 0.5.0 (2026-11-04)
|
||||
|
||||
Published the tested ignitionoee backlog package to the shared Gitea feed on
|
||||
2026-11-04. Independent download matched the reviewed artifact SHA256
|
||||
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`.
|
||||
The ignitionoee router deployed this package to CR on 2026-11-04. No other
|
||||
consumer was deployed in this task (rollout → #9). Do not repack or replace published 0.5.0.
|
||||
Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS
|
||||
from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and
|
||||
`ButtonSize`, plus `ITreeRow`/stateless `TreeTable<TRow>` in `ZB.MOM.WW.Theme`.
|
||||
Tree styles live once in `layout.css`, with arbitrary-depth indentation and
|
||||
keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact.
|
||||
|
||||
Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing
|
||||
NU1902 advisory without disabling audit. Runtime package dependencies are unchanged.
|
||||
Local verification: Release build passed with zero warnings/errors; Release tests
|
||||
passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg.
|
||||
All 19 remaining ignitionoee app.css rule blocks are present verbatim in the
|
||||
shared stylesheet; the Impeccable detector reported no findings on changed UI files.
|
||||
Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid
|
||||
the page through Chromium's disclosure wrapper. The wrapper override now applies
|
||||
at all widths, with an explicit mobile rule hiding only the rail and author
|
||||
display rules for older browsers. Two regression tests cover CSS placement and
|
||||
closed-shell component structure; computed mobile/desktop behavior was checked in
|
||||
the disposable ignitionoee local browser fixture.
|
||||
Browser QA confirmed the visibility fix and isolated an additional mobile
|
||||
min-content sizing issue: bounding the direct page to 100% width/max-width with
|
||||
border-box reduced the page/document from 1055px to the 390px viewport and kept
|
||||
wide-table scrolling inside its wrapper. That measured constraint is included.
|
||||
See README's 0.5.0 migration section; deployed consumer validation remains separate
|
||||
from local source/package/browser verification.
|
||||
|
||||
## Index history (migrated from the scadaproj root CLAUDE.md)
|
||||
|
||||
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
|
||||
|
||||
### Component-table row as it read in the index
|
||||
|
||||
| Component | Status | Goal | Design | Implementation |
|
||||
|---|---|---|---|---|
|
||||
| UI Theme (layout / tokens / components) | Adopted (feed `0.2.0`–`0.4.1`). **`0.4.0` (2026-08-11) upstreamed the Bootstrap `.btn`/`.btn-sm` sizing block** that all **five** consumers had independently written into their own stylesheets (Bootstrap hard-codes `--bs-btn-font-size: 1rem`, oversized against the kit's 0.9rem body) — sized by `--bs-btn-*` variable override **only**, never by redeclaring box properties, because `.btn-group`'s seam/radius machinery reads those same variables and a literal `font-size` at equal specificity silently flattens `.btn-sm` into a padding-only difference (a real defect found in mxgw's local copy). Same release added the `rail-btn-block` full-width rail-foot modifier; **`0.4.1` fixes it** — 0.4.0 shipped `width: auto`, which fills for an `<a>` but shrink-wraps a `<button>` (measured 114px short in the same rail), now `calc(100% - 1.2rem)` to clear `.rail-btn`'s side margins. **Consumers are five, not four** — the ignitionoee router joined. **All five consumers are on `0.4.1` and all five are PUSHED** (2026-08-11): ignitionoee `main @ 38aa5c1`, OtOpcUa `master @ e82881c1`, mxgw `main @ 0a9715d`. The two whose sweeps were not deploy-ready pushed to **branches**, deliberately leaving their default branch untouched: HistorianGateway `dashboard-ui-sweep-theme-0.4.1` — **MERGED to `main` (ff to `e42ff31`) + pushed 2026-08-13**, carrying Theme 0.4.1 + Health 0.3.0 + Secrets 0.6.2 + Auth 0.2.1 and ScadaBridge `ui/central-ui-cleanup-sweep @ 9e243493` (`main` was then `b6f383a2`). **ScadaBridge's caveat is now CLOSED and its sweep is merged, deployed and live** (2026-08-11): the branch landed on `main`, and the two follow-ups `d14e0ee4` + `3eb7df74` fixed exactly the four detail-modal surfaces the caveat named — the modal is now gated on **user intent (the held id)**, never on the row still resolving, so a background refresh that drops the row leaves it mounted and says why the summary is missing instead of unmounting the subtree and disposing Close's own handler mid-click. That disposal *was* the intermittent `GetRequiredEventBindingEntry`, and `NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage` now pins it. **Deployed to wonder-app-vd03: OtOpcUa `e82881c1`, and ScadaBridge `3eb7df74` (2026-08-11 — theme.css/layout.css/nav-state.js served from `_content/ZB.MOM.WW.Theme` and referenced by the pages; all six changed routes 200). HistorianGateway on wonder still runs `e9c31da` = Theme 0.3.1 — its 0.4.1 is now on `main` (merged 2026-08-13) but not yet deployed to the box.** **On bump, TRIM the local `.btn` block, don't delete it** — four of the five were pure sizing, but mxgw's also carried `border-radius`/`font-weight`/`white-space` that a blind delete would have dropped. | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
|
||||
|
||||
### Narrative
|
||||
|
||||
The UI-theme component is fully populated: a normalized [`spec`](components/ui-theme/spec/SPEC.md),
|
||||
a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
|
||||
[`shared-contract`](components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md), three
|
||||
[`current-state`](components/ui-theme/current-state/) docs, and an adoption [`GAPS`](components/ui-theme/GAPS.md)
|
||||
backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + widgets; left
|
||||
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; 48 bUnit tests (re-verified 2026-08-11 at `0.4.1`); feed carries `0.2.0`–`0.4.1`,
|
||||
**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).
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.4.1</Version>
|
||||
<Version>0.5.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="bunit" Version="1.40.0" />
|
||||
<PackageVersion Include="bunit" Version="2.7.2" />
|
||||
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
|
||||
<PackageVersion Include="AngleSharp" Version="1.5.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" />
|
||||
|
||||
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
|
||||
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
|
||||
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
|
||||
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
|
||||
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. |
|
||||
| `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. |
|
||||
| `TreeTable<TRow>` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. |
|
||||
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
|
||||
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
|
||||
|
||||
@@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers |
|
||||
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing |
|
||||
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles |
|
||||
|
||||
**Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its
|
||||
0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes
|
||||
@@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit.
|
||||
```bash
|
||||
# from ZB.MOM.WW.Theme/
|
||||
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
|
||||
dotnet test # 38 bUnit tests
|
||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
|
||||
dotnet test # 60 tests verified for 0.5.0
|
||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg
|
||||
```
|
||||
|
||||
## 0.5.0 migration (published 2026-11-04)
|
||||
|
||||
The tested package was published to the shared Gitea feed on 2026-11-04.
|
||||
An independent feed download exactly matched the reviewed artifact (SHA256
|
||||
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`).
|
||||
The ignitionoee router deployed this package to CR on 2026-11-04; other consumer
|
||||
rollouts remain separate. It retains the 0.4.2 `.rise` animation
|
||||
fill-mode fix. The published package is immutable; this source documentation
|
||||
update does not repack or replace it.
|
||||
|
||||
The shell now keeps page content visible when mobile navigation is collapsed.
|
||||
The outer disclosure's `::details-content` wrapper is dissolved at every width;
|
||||
an explicit mobile closed-state selector hides only the rail. Author display
|
||||
rules retain the page for older browsers without `::details-content` support.
|
||||
The menu remains a native keyboard-accessible disclosure without custom JavaScript.
|
||||
Mobile pages are constrained to the shell width so wide tables scroll inside their
|
||||
table wrapper instead of widening the whole document.
|
||||
|
||||
The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section
|
||||
headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`,
|
||||
**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies
|
||||
after adopting this package. The element-qualified row-action selector must remain:
|
||||
the bare class loses to `.data-table td` regardless of stylesheet load order.
|
||||
|
||||
Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`.
|
||||
`Class="my-action"` and an attribute dictionary's `class` value are composed with
|
||||
the required button classes. A lowercase `class` supplied in Razor binds to `Class`
|
||||
as normal Blazor parameter binding. Supplying both spellings as separate component
|
||||
parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two
|
||||
sources. Explicit `Busy` and `Type` continue to control the underlying attributes.
|
||||
|
||||
Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`,
|
||||
`HasChildren`). Feed `TreeTable<TRow>` visible rows in preorder, with each subtree
|
||||
contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells
|
||||
after the component's name cell; `Label` supplies the name. `OnToggle` reports the
|
||||
stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`.
|
||||
The component never filters or mutates caller state. Indentation supports arbitrary
|
||||
depth through `--tt-depth`; styles ship once, with no per-instance stylesheet.
|
||||
Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive
|
||||
expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons.
|
||||
|
||||
The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids
|
||||
the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg))
|
||||
and the binary incompatibility between bUnit 1.x and the patched parser. These
|
||||
dependencies belong to the test project; the Theme runtime package gains none.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
|
||||
public enum ButtonSize
|
||||
{
|
||||
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
|
||||
Default,
|
||||
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
|
||||
Small,
|
||||
/// <summary>Large button, using <c>btn-lg</c>.</summary>
|
||||
Large,
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@namespace ZB.MOM.WW.Theme
|
||||
@* Components/TechButton.razor *@
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy">
|
||||
<button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
|
||||
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
|
||||
@ChildContent
|
||||
</button>
|
||||
@@ -12,6 +12,12 @@
|
||||
/// </summary>
|
||||
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
|
||||
|
||||
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
|
||||
[Parameter] public ButtonSize Size { get; set; }
|
||||
|
||||
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
|
||||
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
|
||||
@@ -30,6 +36,14 @@
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private string ButtonClass => string.Join(" ", new[]
|
||||
{
|
||||
"btn", VariantClass,
|
||||
Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null },
|
||||
Class,
|
||||
AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString()
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
|
||||
private string VariantClass => Variant switch
|
||||
{
|
||||
ButtonVariant.Secondary => "btn-outline-secondary",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
@namespace ZB.MOM.WW.Theme
|
||||
@typeparam TRow where TRow : ITreeRow
|
||||
|
||||
@* Stateless preorder tree table. The caller filters Rows and owns collapse state.
|
||||
Shared styles ship once in layout.css, including arbitrary-depth indentation. *@
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>@Head</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (TRow row in Rows)
|
||||
{
|
||||
<tr @key="row.Key" class="tt-d@(row.Depth)">
|
||||
<td class="tt-name" style="@DepthStyle(row)">
|
||||
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
|
||||
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
|
||||
@onclick="() => OnToggle.InvokeAsync(row.Key)">
|
||||
<i class="@ChevronClass(row)" aria-hidden="true"></i>
|
||||
</button>
|
||||
@Label(row)
|
||||
</td>
|
||||
@Row(row)
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// The rows to render, in order — already filtered through
|
||||
/// the caller's visibility policy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The filtering is the caller's so that this component holds no state at all: the page owns
|
||||
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
|
||||
/// </remarks>
|
||||
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The header cells — every <c><th></c>, including the one over the name column. The
|
||||
/// component supplies the <c><thead></c> and its <c><tr></c>.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
|
||||
|
||||
/// <summary>
|
||||
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
|
||||
/// caller renders the rest.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
|
||||
|
||||
/// <summary>The text of a row's name cell, beside its twist.</summary>
|
||||
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The keys of the collapsed rows — read only to draw each twist the right way round.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
|
||||
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
|
||||
/// </remarks>
|
||||
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
|
||||
/// toggles that key in its own collapse set; this component changes nothing itself.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||
|
||||
private static string DepthStyle(TRow row)
|
||||
=> "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key);
|
||||
|
||||
private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf";
|
||||
|
||||
private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down";
|
||||
|
||||
// Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed
|
||||
// control would promise a subtree that is not there.
|
||||
private string? AriaExpanded(TRow row)
|
||||
=> !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true";
|
||||
|
||||
private string? TwistLabel(TRow row)
|
||||
=> !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
|
||||
public interface ITreeRow
|
||||
{
|
||||
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
|
||||
string Key { get; }
|
||||
|
||||
/// <summary>Nesting depth, starting at zero for a root.</summary>
|
||||
int Depth { get; }
|
||||
|
||||
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
|
||||
bool HasChildren { get; }
|
||||
}
|
||||
@@ -11,10 +11,12 @@
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Theme</PackageId>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ZB.MOM.WW.Theme — side-rail + login layout
|
||||
Tokens live in theme.css; this sheet carries only layout + the side rail. */
|
||||
Tokens live in theme.css; this sheet carries application layout and shared controls. */
|
||||
|
||||
/* ── Bootstrap button sizing ─────────────────────────────────────────────────
|
||||
Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against
|
||||
@@ -45,6 +45,12 @@
|
||||
--bs-btn-padding-y: .25rem;
|
||||
--bs-btn-padding-x: .6rem;
|
||||
}
|
||||
.btn-group-lg > .btn,
|
||||
.btn-lg {
|
||||
--bs-btn-font-size: 1rem;
|
||||
--bs-btn-padding-y: .5rem;
|
||||
--bs-btn-padding-x: 1rem;
|
||||
}
|
||||
|
||||
/* ── App shell: side rail + page ─────────────────────────────────────────── */
|
||||
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
|
||||
@@ -59,6 +65,21 @@
|
||||
min-height: calc(100vh - 3.3rem);
|
||||
}
|
||||
|
||||
/* A closed shell must hide navigation only, never the page. Modern Chromium
|
||||
wraps all non-summary children in ::details-content and hides that box while
|
||||
closed. Dissolve it at EVERY width so both children retain the shell's flex
|
||||
layout. The mobile closed-rail rule below owns navigation visibility. */
|
||||
.app-shell::details-content {
|
||||
display: contents;
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
/* Older browsers use a UA display rule on closed disclosure children instead
|
||||
of ::details-content. Author display rules keep the page present there too;
|
||||
an unsupported pseudo-element rule above is simply ignored. */
|
||||
.app-shell > main.page { display: block; }
|
||||
.app-shell > #theme-rail { display: block; }
|
||||
|
||||
.app-shell .page {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -72,9 +93,8 @@
|
||||
}
|
||||
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
|
||||
|
||||
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing
|
||||
extra is needed there. On lg+ we force the rail visible (see media query)
|
||||
and keep the toggle hidden via Bootstrap's d-lg-none. */
|
||||
/* The native <details> owns its open state; the mobile rule explicitly hides
|
||||
only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
|
||||
|
||||
/* ── Side rail ───────────────────────────────────────────────────────────── */
|
||||
.side-rail {
|
||||
@@ -92,20 +112,6 @@
|
||||
and force it shown regardless of the <details> open state (the hamburger
|
||||
toggle is hidden at this width). */
|
||||
@media (min-width: 992px) {
|
||||
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
|
||||
box that carries content-visibility:hidden while the <details> is closed.
|
||||
Because our app-shell ships closed by default (SSR, no JS) and the toggle
|
||||
is d-lg-none here, that wrapper would (a) hide the rail+page entirely on
|
||||
lg+ and (b) sit between .app-shell and its rail/page children, collapsing
|
||||
the flex-lg-row layout into a vertical stack. Dissolving the wrapper with
|
||||
display:contents removes its box (so content-visibility no longer hides the
|
||||
content AND rail/page become direct flex children of .app-shell again).
|
||||
Browsers that don't support ::details-content treat this as an invalid
|
||||
selector and drop the rule, falling back to the legacy force-show below. */
|
||||
.app-shell::details-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#theme-rail {
|
||||
display: block;
|
||||
position: sticky;
|
||||
@@ -116,10 +122,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* When the side rail is collapsed under <lg viewports (the <details> is closed)
|
||||
the native disclosure hides #theme-rail; when open, restore full width on
|
||||
mobile so the rail spans the viewport. */
|
||||
/* The disclosure toggles navigation while the page remains usable underneath. */
|
||||
@media (max-width: 991.98px) {
|
||||
.app-shell:not([open]) > #theme-rail { display: none; }
|
||||
/* Native disclosure layout can otherwise size the page to a table's
|
||||
min-content width. Bound the page so .table-wrap scrolls internally. */
|
||||
.app-shell > .page {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.side-rail {
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
@@ -323,3 +335,79 @@
|
||||
.login-body { padding: 1.4rem 1.1rem 1.25rem; }
|
||||
.login-error { margin-bottom: 0.85rem; }
|
||||
.field-error { font-size: 0.78rem; margin-top: 0.2rem; }
|
||||
|
||||
/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */
|
||||
/* ── Page header ─────────────────────────────────────────────────────────────
|
||||
Title left, `.spacer` eating the slack, actions/meta pushed hard right. */
|
||||
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
|
||||
.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; }
|
||||
.page-head .spacer { flex: 1; }
|
||||
.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); }
|
||||
|
||||
/* ── Application sections ────────────────────────────────────────────────────
|
||||
A rule-underlined h2 band; same spacer trick as the page head. */
|
||||
.app-section { margin-bottom: 1.5rem; }
|
||||
.app-head {
|
||||
display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
|
||||
padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem;
|
||||
}
|
||||
.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; }
|
||||
.app-head .spacer { flex: 1; }
|
||||
.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); }
|
||||
|
||||
/* ── Group header (per-site issue groups) ────────────────────────────────── */
|
||||
.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
||||
.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); }
|
||||
.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); }
|
||||
|
||||
/* ── Per-group count badge ───────────────────────────────────────────────────
|
||||
Red by default (it counts problems); `.warn` for the softer amber case. */
|
||||
.count-badge {
|
||||
display: inline-block; min-width: 1.4rem; text-align: center;
|
||||
font-family: var(--mono); font-size: 0.72rem; font-weight: 600;
|
||||
padding: 0.05rem 0.35rem; border-radius: 999px;
|
||||
color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border);
|
||||
}
|
||||
.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
|
||||
|
||||
/* ── Inline notice, info variant ─────────────────────────────────────────────
|
||||
Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to
|
||||
the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */
|
||||
.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
|
||||
|
||||
/* ── Table row-action cell ─────────────────────────────────────────────────
|
||||
The bare class is what the mockup ships and what a `<span class="row-actions">`
|
||||
still needs. It is NOT enough on its own: every use in this app is a table
|
||||
cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }`
|
||||
is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity
|
||||
beats load order, so the alignment silently lost everywhere. Observed in a
|
||||
browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied,
|
||||
`text-align` did not); the cell-level restatement below is the fix.
|
||||
See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */
|
||||
.row-actions { text-align: right; white-space: nowrap; }
|
||||
td.row-actions { text-align: right; }
|
||||
|
||||
/* ── Preformatted check/payload block ────────────────────────────────────── */
|
||||
.check-data {
|
||||
margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg);
|
||||
border: 1px solid var(--rule); border-radius: 4px;
|
||||
font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6;
|
||||
overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
.check-data b { color: var(--accent-deep); font-weight: 500; }
|
||||
|
||||
/* ── Tree table ──────────────────────────────────────────────────────────────
|
||||
Name-cell depth is a number emitted by TreeTable; it adds to the table's
|
||||
standard 0.8rem cell padding and works at every depth. */
|
||||
.data-table td.tt-name {
|
||||
font-weight: 600;
|
||||
padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem);
|
||||
}
|
||||
.tt-twist {
|
||||
background: none; border: none; padding: 0; margin-right: 0.45rem;
|
||||
color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.tt-twist:hover { color: var(--ink); }
|
||||
.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
/* A hidden leaf control preserves the width of its siblings' toggles. */
|
||||
.tt-leaf { visibility: hidden; }
|
||||
|
||||
@@ -225,7 +225,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
Add .rise to top-level sections; stagger with inline animation-delay
|
||||
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
|
||||
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||||
.rise { animation: rise 0.4s ease both; }
|
||||
.rise { animation: rise 0.4s ease backwards; /* backwards: end keyframe equals natural style, preventing a containing-block trap for position:fixed descendants */ }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
COMPONENT LIBRARY
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class BrandBarTests : TestContext
|
||||
public class BrandBarTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void Renders_product_with_default_mark()
|
||||
{
|
||||
var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
Assert.Contains("OtOpcUa", cut.Markup);
|
||||
Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
|
||||
}
|
||||
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
|
||||
[Fact]
|
||||
public void Custom_logo_replaces_default_mark()
|
||||
{
|
||||
var cut = RenderComponent<BrandBar>(p => p
|
||||
var cut = Render<BrandBar>(p => p
|
||||
.Add(x => x.Product, "ScadaBridge")
|
||||
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
|
||||
Assert.NotNull(cut.Find(".brand .logo"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class CommonControlsTests : TestContext
|
||||
public class CommonControlsTests : BunitContext
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ButtonVariant.Primary, "btn-primary")]
|
||||
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
|
||||
[InlineData(ButtonVariant.Ghost, "btn-link")]
|
||||
public void TechButton_maps_variant(ButtonVariant v, string cls)
|
||||
{
|
||||
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
|
||||
var cut = Render<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
|
||||
var btn = cut.Find("button");
|
||||
Assert.Contains("btn", btn.ClassList);
|
||||
Assert.Contains(cls, btn.ClassList);
|
||||
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
|
||||
[Fact]
|
||||
public void TechButton_busy_disables_and_passes_through_attributes()
|
||||
{
|
||||
var cut = RenderComponent<TechButton>(p => p
|
||||
var cut = Render<TechButton>(p => p
|
||||
.Add(x => x.Busy, true)
|
||||
.AddUnmatched("id", "save")
|
||||
.AddChildContent("Save"));
|
||||
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
|
||||
[Fact]
|
||||
public void TechButton_not_busy_is_not_disabled()
|
||||
{
|
||||
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
|
||||
var cut = Render<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
|
||||
Assert.False(cut.Find("button").HasAttribute("disabled"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TechCard_renders_title_and_body()
|
||||
{
|
||||
var cut = RenderComponent<TechCard>(p => p
|
||||
var cut = Render<TechCard>(p => p
|
||||
.Add(x => x.Title, "Drivers")
|
||||
.AddChildContent("<div class='b'>x</div>"));
|
||||
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
|
||||
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
|
||||
[Fact]
|
||||
public void TechField_renders_label_hint_error()
|
||||
{
|
||||
var cut = RenderComponent<TechField>(p => p
|
||||
var cut = Render<TechField>(p => p
|
||||
.Add(x => x.Label, "Name")
|
||||
.Add(x => x.Hint, "required")
|
||||
.Add(x => x.Error, "missing")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class LoginCardTests : TestContext
|
||||
public class LoginCardTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void Posts_to_action_with_username_password_fields()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.Action, "/auth/login"));
|
||||
var form = cut.Find("form");
|
||||
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
|
||||
[Fact]
|
||||
public void ReturnUrl_renders_hidden_input()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.ReturnUrl, "/clusters"));
|
||||
var hidden = cut.Find("input[name=returnUrl]");
|
||||
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
|
||||
[Fact]
|
||||
public void Error_renders_notice()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.Error, "Bad credentials"));
|
||||
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
|
||||
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
|
||||
[Fact]
|
||||
public void No_returnUrl_no_hidden_input()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
Assert.Empty(cut.FindAll("input[name=returnUrl]"));
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
|
||||
[Fact]
|
||||
public void Product_is_wrapped_in_login_product_span()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var product = cut.Find(".login-title .login-product");
|
||||
Assert.Equal("OtOpcUa", product.TextContent);
|
||||
Assert.Contains("sign in", cut.Find(".login-title").TextContent);
|
||||
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
|
||||
[Fact]
|
||||
public void Heading_overrides_default_heading_when_set()
|
||||
{
|
||||
var cut = RenderComponent<LoginCard>(p => p
|
||||
var cut = Render<LoginCard>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.Heading, "Welcome back"));
|
||||
var title = cut.Find(".login-title");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class NavRailTests : TestContext
|
||||
public class NavRailTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void NavRailItem_renders_rail_link_with_href_and_text()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters"));
|
||||
var a = cut.Find("a.rail-link");
|
||||
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters")
|
||||
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
|
||||
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailItem_omits_rail_ico_span_when_no_icon()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
var cut = Render<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters"));
|
||||
Assert.Empty(cut.FindAll(".rail-ico"));
|
||||
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_renders_title_and_children_open_by_default()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
var details = cut.Find("details.rail-section");
|
||||
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_summary_aria_expanded_true_when_open()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
|
||||
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_collapsed_when_not_expanded()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
|
||||
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_summary_aria_expanded_false_when_collapsed()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
|
||||
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Site Calls")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_emits_explicit_key_when_supplied()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, " ")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
|
||||
[Fact]
|
||||
public void NavRailSection_slug_preserves_unicode_letters()
|
||||
{
|
||||
var cut = RenderComponent<NavRailSection>(p => p
|
||||
var cut = Render<NavRailSection>(p => p
|
||||
.Add(x => x.Title, "Café")
|
||||
.AddChildContent("<a class='rail-link'>X</a>"));
|
||||
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
|
||||
|
||||
@@ -39,6 +39,36 @@ public class StaticAssetsTests
|
||||
public void NavStateScript_ships() =>
|
||||
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
|
||||
|
||||
[Fact]
|
||||
public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers()
|
||||
{
|
||||
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
|
||||
Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css);
|
||||
foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head",
|
||||
".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge",
|
||||
".count-badge.warn", ".notice.info", ".row-actions", ".check-data" })
|
||||
Assert.Contains(selector, css);
|
||||
Assert.Contains("var(--tt-depth, 0)", css);
|
||||
Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css);
|
||||
Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible()
|
||||
{
|
||||
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
|
||||
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
|
||||
var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)];
|
||||
Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules);
|
||||
Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules);
|
||||
var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..];
|
||||
Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile);
|
||||
var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value;
|
||||
Assert.Matches(@"\bwidth:\s*100%", page);
|
||||
Assert.Matches(@"\bmax-width:\s*100%", page);
|
||||
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
|
||||
}
|
||||
|
||||
// Theme-002: .chip-idle pairs the idle background with the matching --idle
|
||||
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
|
||||
[Fact]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class StatusPillTests : TestContext
|
||||
public class StatusPillTests : BunitContext
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(StatusState.Ok, "chip-ok")]
|
||||
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
|
||||
[InlineData(StatusState.Info, "chip-info")]
|
||||
public void Maps_state_to_chip_class(StatusState state, string expected)
|
||||
{
|
||||
var cut = RenderComponent<StatusPill>(p => p
|
||||
var cut = Render<StatusPill>(p => p
|
||||
.Add(x => x.State, state)
|
||||
.AddChildContent("Connected"));
|
||||
var span = cut.Find("span");
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public sealed class TechButtonExtensionsTests : BunitContext
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(ButtonSize.Default, null)]
|
||||
[InlineData(ButtonSize.Small, "btn-sm")]
|
||||
[InlineData(ButtonSize.Large, "btn-lg")]
|
||||
public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected)
|
||||
{
|
||||
var cut = Render<TechButton>(p => p
|
||||
.Add(x => x.Variant, ButtonVariant.Secondary)
|
||||
.Add(x => x.Size, size)
|
||||
.Add(x => x.Class, "explicit-action")
|
||||
.Add(x => x.AdditionalAttributes, new Dictionary<string, object>
|
||||
{
|
||||
["class"] = "caller-action", ["aria-label"] = "Save changes"
|
||||
})
|
||||
.AddChildContent("Save"));
|
||||
|
||||
var button = cut.Find("button");
|
||||
foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" })
|
||||
Assert.Contains(cls, button.ClassList);
|
||||
Assert.Equal("Save changes", button.GetAttribute("aria-label"));
|
||||
if (expected is not null) Assert.Contains(expected, button.ClassList);
|
||||
else
|
||||
{
|
||||
Assert.DoesNotContain("btn-sm", button.ClassList);
|
||||
Assert.DoesNotContain("btn-lg", button.ClassList);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Class_composition_keeps_busy_type_and_click_behavior()
|
||||
{
|
||||
var clicks = 0;
|
||||
var attributes = new Dictionary<string, object>
|
||||
{
|
||||
["class"] = "caller", ["disabled"] = false,
|
||||
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
|
||||
};
|
||||
var cut = Render<TechButton>(p => p
|
||||
.Add(x => x.Class, "extra")
|
||||
.Add(x => x.Type, "submit")
|
||||
.Add(x => x.Busy, true)
|
||||
.Add(x => x.AdditionalAttributes, attributes)
|
||||
.AddChildContent("Save"));
|
||||
|
||||
Assert.True(cut.Find("button").HasAttribute("disabled"));
|
||||
Assert.Equal("submit", cut.Find("button").GetAttribute("type"));
|
||||
Assert.NotNull(cut.Find(".spinner-border"));
|
||||
Assert.Equal("caller", attributes["class"]);
|
||||
cut.Render(p => p.Add(x => x.Busy, false));
|
||||
cut.Find("button").Click();
|
||||
Assert.Equal(1, clicks);
|
||||
Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lowercase_class_attribute_preserves_required_button_classes()
|
||||
{
|
||||
var cut = Render<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
|
||||
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
|
||||
Assert.Contains(cls, cut.Find("button").ClassList);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class ThemeHeadTests : TestContext
|
||||
public class ThemeHeadTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void Emits_theme_and_layout_links_to_content_path()
|
||||
{
|
||||
var cut = RenderComponent<ThemeHead>();
|
||||
var cut = Render<ThemeHead>();
|
||||
var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList();
|
||||
Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
|
||||
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class ThemeScriptsTests : TestContext
|
||||
public class ThemeScriptsTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void ThemeScripts_emits_nav_state_script_tag()
|
||||
{
|
||||
var cut = RenderComponent<ThemeScripts>();
|
||||
var cut = Render<ThemeScripts>();
|
||||
var script = cut.Find("script");
|
||||
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
|
||||
Assert.True(script.HasAttribute("defer"));
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public class ThemeShellTests : TestContext
|
||||
public class ThemeShellTests : BunitContext
|
||||
{
|
||||
[Fact]
|
||||
public void Renders_product_nav_and_body()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p
|
||||
var cut = Render<ThemeShell>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
|
||||
.AddChildContent("<div class='pagebody'>BODY</div>"));
|
||||
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
|
||||
[Fact]
|
||||
public void Accent_sets_css_variable_on_shell_root()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p
|
||||
var cut = Render<ThemeShell>(p => p
|
||||
.Add(x => x.Product, "ScadaBridge")
|
||||
.Add(x => x.Accent, "#2f855a"));
|
||||
var shell = cut.Find(".app-shell");
|
||||
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
|
||||
[Fact]
|
||||
public void No_accent_emits_no_style()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
|
||||
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
|
||||
Assert.False(cut.Find(".app-shell").HasAttribute("style"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RailFooter_renders_when_supplied()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p
|
||||
var cut = Render<ThemeShell>(p => p
|
||||
.Add(x => x.Product, "OtOpcUa")
|
||||
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
|
||||
Assert.NotNull(cut.Find(".rail-foot .sess"));
|
||||
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
|
||||
[Fact]
|
||||
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
|
||||
// Root is a <details> whose direct <summary> is the hamburger toggle.
|
||||
var shell = cut.Find("details.app-shell");
|
||||
@@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext
|
||||
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
|
||||
Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail()
|
||||
{
|
||||
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OEE")
|
||||
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
|
||||
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
|
||||
|
||||
Assert.False(cut.Find("details.app-shell").HasAttribute("open"));
|
||||
Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav"));
|
||||
Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent);
|
||||
Assert.NotNull(cut.Find("details.app-shell > main.page > input"));
|
||||
Assert.Empty(cut.FindAll("#theme-rail main"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
public sealed class TreeTableTests : BunitContext
|
||||
{
|
||||
private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow;
|
||||
|
||||
private IRenderedComponent<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
|
||||
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
|
||||
=> Render<TreeTable<TestRow>>(p => p
|
||||
.Add(x => x.Rows, rows)
|
||||
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
|
||||
.Add(x => x.Label, row => row.Name)
|
||||
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
|
||||
.Add(x => x.Row, row => builder =>
|
||||
{
|
||||
builder.OpenElement(0, "td");
|
||||
builder.AddContent(1, "Value for " + row.Name);
|
||||
builder.CloseElement();
|
||||
})
|
||||
.Add(x => x.OnToggle, key => toggle?.Invoke(key)));
|
||||
|
||||
[Fact]
|
||||
public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets()
|
||||
{
|
||||
var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]);
|
||||
Assert.Equal(2, cut.FindAll("thead th").Count);
|
||||
Assert.Equal(2, cut.FindAll("tbody tr").Count);
|
||||
Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent);
|
||||
Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent);
|
||||
Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style"));
|
||||
Assert.Empty(cut.FindAll("style"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Leaf_keeps_alignment_without_promising_a_subtree()
|
||||
{
|
||||
var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]);
|
||||
var twist = cut.Find("button");
|
||||
Assert.Contains("tt-leaf", twist.ClassList);
|
||||
Assert.True(twist.HasAttribute("disabled"));
|
||||
Assert.False(twist.HasAttribute("aria-expanded"));
|
||||
Assert.False(twist.HasAttribute("aria-label"));
|
||||
Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_reports_stable_key_and_caller_controls_expansion()
|
||||
{
|
||||
string? toggled = null;
|
||||
var cut = RenderTreeTable([new("site-key", 0, true, "Site")],
|
||||
new HashSet<string> { "site-key" }, key => toggled = key);
|
||||
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
|
||||
Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label"));
|
||||
cut.Find("button").Click();
|
||||
Assert.Equal("site-key", toggled);
|
||||
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
|
||||
cut.Render(p => p.Add(x => x.Collapsed, new HashSet<string>()));
|
||||
Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded"));
|
||||
Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label"));
|
||||
Assert.Contains("bi-chevron-down", cut.Find("i").ClassList);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_rows_keep_the_header_and_empty_body()
|
||||
{
|
||||
var cut = RenderTreeTable([]);
|
||||
Assert.Equal(2, cut.FindAll("thead th").Count);
|
||||
Assert.Empty(cut.FindAll("tbody tr"));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="bunit" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
|
||||
@@ -8,6 +8,11 @@ place to see all outstanding work.
|
||||
> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not
|
||||
> edit by hand — edit the findings files and re-run the script.
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work (including new review findings) as an issue.
|
||||
|
||||
## How it works
|
||||
|
||||
- Reviews are performed one library at a time against a fixed checklist.
|
||||
@@ -37,6 +42,9 @@ code-reviews/
|
||||
|
||||
6 of 6 libraries reviewed. 0 pending findings across all libraries.
|
||||
|
||||
Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,
|
||||
and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.
|
||||
|
||||
| Severity | Open findings |
|
||||
|----------|---------------|
|
||||
| Critical | 0 |
|
||||
|
||||
@@ -97,6 +97,11 @@ def build_readme(libraries, per_library):
|
||||
add("> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not")
|
||||
add("> edit by hand — edit the findings files and re-run the script.")
|
||||
add("")
|
||||
add("> **Status lives in Gitea.** Open items from this file are tracked as issues on")
|
||||
add("> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)")
|
||||
add("> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status")
|
||||
add("> here; file new work (including new review findings) as an issue.")
|
||||
add("")
|
||||
add("## How it works")
|
||||
add("")
|
||||
add("- Reviews are performed one library at a time against a fixed checklist.")
|
||||
@@ -127,6 +132,9 @@ def build_readme(libraries, per_library):
|
||||
add(f"{reviewed} of {len(libraries)} libraries reviewed. "
|
||||
f"{len(pending)} pending finding{'s' if len(pending) != 1 else ''} across all libraries.")
|
||||
add("")
|
||||
add("Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,")
|
||||
add("and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.")
|
||||
add("")
|
||||
add("| Severity | Open findings |")
|
||||
add("|----------|---------------|")
|
||||
for sev in ("Critical", "High", "Medium", "Low"):
|
||||
|
||||
@@ -51,8 +51,13 @@ Every normalized component is one subfolder of `components/` with this layout:
|
||||
- **`current-state/<project>/`** is descriptive, not aspirational. It must match the project's
|
||||
real code, with `file:line` references. Each ends in an **Adoption plan**: what that project
|
||||
deletes/replaces to reach the spec, and what stays bespoke.
|
||||
- **`GAPS.md`** is the working backlog: it turns the delta between every `current-state` and the
|
||||
`spec` into concrete, prioritized items.
|
||||
- **`GAPS.md`** turns the delta between every `current-state` and the `spec` into concrete,
|
||||
prioritized items — but **status lives in Gitea** (family standard, 2026-11-06). Open items are
|
||||
tracked as issues on [`dohertj2/scadaproj`](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
(the 2026-11-06 sweep filed them with label `migrated`); each GAPS file carries a banner and
|
||||
`→ #N` pointers next to migrated items, and is otherwise a historical/design record. File new gaps
|
||||
as issues (family labels `type:*`, `priority:p0..p3`, `blocked`, `cross-repo`) and reference them
|
||||
from GAPS/current-state docs as `#N` — do not track open/closed status in the markdown.
|
||||
|
||||
## Workflow to normalize a component
|
||||
|
||||
@@ -66,7 +71,7 @@ Every normalized component is one subfolder of `components/` with this layout:
|
||||
concrete library API in `shared-contract/`. Keep the surface minimal — extract only what is
|
||||
genuinely common; leave domain-specific logic in the projects.
|
||||
4. **Log gaps + adoption.** Fill `GAPS.md` with per-project divergences and an adoption/extraction
|
||||
backlog (priority / effort / risk). Add the per-project "what to delete/replace" to each
|
||||
backlog (priority / effort / risk), and file each open item as a Gitea issue (`→ #N` in the file). Add the per-project "what to delete/replace" to each
|
||||
`current-state` doc.
|
||||
5. **Register.** Add/maintain the component's row in the registry table above.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Audit — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Auth — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
@@ -134,5 +139,5 @@ adoption is opt-in per project, one consumer version-bump at a time.
|
||||
|
||||
## Decisions still open
|
||||
- Shared dev base DN value (A3).
|
||||
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3).
|
||||
- Shared JWT/refresh helper or not (#7).
|
||||
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3). → #20
|
||||
- Shared JWT/refresh helper or not (#7). → #20
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Configuration validation — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
|
||||
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests, re-run
|
||||
green 2026-07-18) at
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Health — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
@@ -13,7 +18,7 @@ reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 part
|
||||
> **Caveat on the evidence:** this was verified by `PackageReference` + registration-call grep,
|
||||
> which proves the library is wired in — *not* that every per-app probe below is registered or
|
||||
> that the endpoint tiers match the spec. The divergence tables further down were written
|
||||
> pre-adoption and have **not** been re-verified against current code; treat them as historical
|
||||
> pre-adoption and have **not** been re-verified against current code (→ #11); treat them as historical
|
||||
> until someone re-walks them. They also predate HistorianGateway, which is absent from every
|
||||
> table below.
|
||||
|
||||
@@ -181,12 +186,12 @@ Plan + design: [`../../docs/plans/2026-06-01-health-library-adoption.md`](../../
|
||||
|
||||
### Deferred (verified ill-fitting on adoption — re-scoped from the original backlog)
|
||||
|
||||
- **#4 downstream gRPC dependency probes — DROPPED for now.** Neither repo holds a host-level
|
||||
- **#4 downstream gRPC dependency probes — DROPPED for now.** (→ #11) Neither repo holds a host-level
|
||||
`GrpcChannel` to probe: the MxGateway↔worker IPC is **named pipes** (not gRPC), and OtOpcUa's
|
||||
gateway channel is created **per-driver** from DB config (no DI-level channel). MxGateway readiness
|
||||
instead probes the SQLite auth store (`AuthStoreHealthCheck`). A real worker/downstream probe needs
|
||||
a custom non-gRPC check — future work.
|
||||
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** ScadaBridge's `IActiveNodeGate`
|
||||
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** (→ #11) ScadaBridge's `IActiveNodeGate`
|
||||
is `…ScadaBridge.InboundAPI.IActiveNodeGate`, wired into inbound-API endpoint gating — a different
|
||||
interface from the shared `ZB.MOM.WW.Health.IActiveNodeGate`. Unification touches the InboundAPI
|
||||
path; its existing `ActiveNodeGate` (logic identical to the shared `AkkaActiveNodeGate`) was kept.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Observability — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
@@ -17,7 +22,7 @@ reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 p
|
||||
> listener, OTLP opt-in wiring) were **all subsequently executed the same day** — see the
|
||||
> "Follow-ons — DONE 2026-06-01" table at the bottom of this file, which this banner previously
|
||||
> contradicted (reconciled 2026-08-07). Only the operational items under "Still open" there
|
||||
> (deploying an OTel collector; further ScadaBridge instruments) remain.
|
||||
> (deploying an OTel collector → #13; further ScadaBridge instruments → ScadaBridge#81) remain.
|
||||
|
||||
## Divergence vs spec
|
||||
|
||||
@@ -225,7 +230,7 @@ sister apps in one pass, behaviour-preserving. Each adoption landed on a per-rep
|
||||
precedence and silently drop the knob. So ScadaBridge keeps the factory and only **adds the shared
|
||||
`TraceContextEnricher`** to it — gaining trace↔log correlation without regressing the contract. Full
|
||||
`AddZbSerilog` adoption for ScadaBridge would first require teaching the shared bootstrap to accept a
|
||||
caller-supplied minimum-level governance hook.
|
||||
caller-supplied minimum-level governance hook (→ #12).
|
||||
- **MxGateway keeps `GatewayLogScope` + request-logging middleware as-is.** The Serilog MEL provider
|
||||
captures MEL `BeginScope` dictionaries as structured properties, so the scope/correlation code keeps
|
||||
producing the same properties under Serilog. Only the provider swap + the `ILogRedactor` adapter were
|
||||
@@ -248,7 +253,7 @@ behaviour-preserving except the intentional, no-consumer-yet metric-shape change
|
||||
|
||||
**Still open (not code — operational/future):**
|
||||
|
||||
- **OTLP is opt-in but unexercised** until an OTel collector endpoint is deployed and the
|
||||
- **OTLP is opt-in but unexercised** (→ #13) until an OTel collector endpoint is deployed and the
|
||||
`<App>:Telemetry:Exporter=Otlp` + `:OtlpEndpoint` config is set. The wiring is in place; only a
|
||||
collector is missing.
|
||||
- **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking).
|
||||
- **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking). → ScadaBridge#81
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Secrets — GAPS (adoption backlog)
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
The delta between each project's [`current-state`](current-state/) and the
|
||||
[`SPEC`](spec/SPEC.md), as prioritized work. The library itself is **built + published +
|
||||
reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib
|
||||
@@ -73,7 +78,7 @@ construction).
|
||||
every central version a transitive ceiling, demanding `Google.Protobuf`, `Grpc.Net.Client`,
|
||||
`Microsoft.Data.SqlClient` and `Newtonsoft.Json` bumps. That is a gRPC/data-access change to a
|
||||
production SCADA platform and deserves its own reviewed commit. **Still open as a follow-up** —
|
||||
it is the better long-term posture.
|
||||
it is the better long-term posture. → ScadaBridge#79
|
||||
- ✅ `0.2.0` **PUBLISHED to the Gitea feed 2026-07-18** (all 5 packages; restore-verified from a
|
||||
scratch consumer — resolves `SQLitePCLRaw.lib.e_sqlite3 2.1.12`, `Microsoft.Data.SqlClient 6.0.2`,
|
||||
`Akka.Cluster.Tools 1.5.62`, and scans clean). Merged to `main` @ `15ef1f3`.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# UI Theme — gaps & adoption backlog
|
||||
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
|
||||
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
|
||||
|
||||
@@ -35,7 +40,7 @@ reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap ·
|
||||
> **MxGateway** shed the dead `.sidebar` block + orphaned `.dashboard-login`/`.login-card` (−106), keeping
|
||||
> `.app-bar` (still used by `/denied`) + the `.chip` override. Each verified unreferenced before removal; all
|
||||
> three build clean (0 warn/0 err). OtOpcUa's copy was the notable one — it *overrode* the kit, not just dead code.
|
||||
> **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review; and ScadaBridge's `Host` consumes the
|
||||
> **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review (→ #10); and ScadaBridge's `Host` consumes the
|
||||
> kit only **transitively via `CentralUI`** (no direct `PackageReference`) — builds green, but an implicit dependency.
|
||||
>
|
||||
> _Feed note: the same audit re-confirmed `ZB.MOM.WW.Theme 0.2.0` **is** genuinely on the Gitea feed (registration
|
||||
|
||||
+27
-8
@@ -27,7 +27,7 @@
|
||||
| **OtOpcUa `otopcua-dev`** | This Mac | 3 independent Akka clusters (MAIN + SITE-A + SITE-B) sharing one ConfigDb | `http://localhost:9200` |
|
||||
| **MxAccessGateway** | `windev` (10.100.0.48), Windows | Windows-native gRPC gateway + per-session x86 worker (no Docker) | `http://10.100.0.48:5120` (gRPC) |
|
||||
| **DEV (VD03)** | `wonder-app-vd03.zmr.zimmer.com` | Single-node, all-in-one: ScadaBridge + OtOpcUa + MxGateway + HistorianGateway + Overview | see `docs/operations/` runbooks |
|
||||
| **QA (VQ03/VQ04)** | `wonder-app-vq03` + `wonder-app-vq04` (+ `wonder-sql-vq03`) | **2-node HA pair** — ScadaBridge central+site pairs, OtOpcUa pair; singletons on VQ03 | `http://wonder-app-vq03.zmr.zimmer.com:5320` (Overview) |
|
||||
| **QA (VQ03/VQ04)** | `wonder-app-vq03` + `wonder-app-vq04` (+ `wonder-sql-vq03`) | **2-node HA pair** — ScadaBridge central+site pairs, OtOpcUa pair; MxGateway/HistorianGateway/Overview on BOTH nodes | `http://wonder-app-vq03.zmr.zimmer.com:5320` (Overview) |
|
||||
|
||||
The three ScadaBridge stacks share one external Docker network **`scadabridge-net`**; the
|
||||
OtOpcUa `otopcua-dev` stack runs on its **own** default network (`otopcua-dev_default`) and is
|
||||
@@ -84,8 +84,8 @@ Host windev
|
||||
|---|---|---|
|
||||
| **windev** (Win host) | `ssh windev` | Configured alias; user `dohertj2`, key `id_ed25519`, port 22 |
|
||||
| **fixture host** | `ssh dohertj2@10.100.0.35` | In `known_hosts`; **no** config alias — pass user explicitly; port 22, key-based |
|
||||
| **VD03 (DEV)** | `ssh dohertj2@wonder-app-vd03.zmr.zimmer.com -p 2222` | In `known_hosts` on **port 2222** (servecli, not OpenSSH); user/key not pinned in config — confirm before use. Durable key `~/.ssh/servecli_wonder`. **servecli does not survive a reboot** — if it's down, use RDP |
|
||||
| **VQ03 / VQ04 (QA)** | `psbridge --server wonder-app-vq0{3,4}.zmr.zimmer.com:6150 <cmd>` | **The supported path since 2026-08-14** (servecli `2222` is retired on these boxes — see the banner note below). Root jail `E:\psrem`; see the `psbridge` skill |
|
||||
| **VD03 / VQ03 / VQ04 (all wonder boxes)** | `winrm ps vd03 '<PowerShell>'` · `winrm smb push vq03 ./x.zip 'E$\psrem-stage\x.zip'` | **The supported path since 2026-09-10** (Claude Code skill `winrm`, `~/.local/bin/winrm`): WinRM **5985/NTLM** for commands (5986 closed), **SMB 445** on the `E$` admin share for files, nothing installed on the box, survives reboots. Profiles `vd03`/`vq03`/`vq04` (FQDN `.zmr.zimmer.com`); the domain password lives only in the macOS Keychain. No second hop under NTLM (use SQL logins in scripts). First full round: all five apps on all three boxes, 2026-09-10 |
|
||||
| ~~VD03 servecli~~ | `ssh … -p 2222` | **Retired** — plain process, dies on reboot; the psbridge skill/binary were removed the same day (its `server.exe` on `:6150` may still be running on the QA boxes but nothing uses it) |
|
||||
| **local Linux VMs** | `ssh orb` / `orb` | OrbStack-managed |
|
||||
|
||||
> ⚠️ **A TCP-open `2222` does NOT mean SSH works.** On 2026-08-13 all three wonder boxes
|
||||
@@ -380,10 +380,12 @@ each was established:
|
||||
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
|
||||
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
|
||||
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
|
||||
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) |
|
||||
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same registry — 12 instances since 2026-10-01, BTDB Manager added) |
|
||||
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
|
||||
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
|
||||
| **6150** | ✅ | ✅ | live | **psbridge** remote-admin bridge (`server.exe`, root jail `E:\psrem`) — **the supported access path**; see §2.1 |
|
||||
| **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 |
|
||||
| **445** | ✅ | ✅ | live | SMB — `E$`/`C$` admin shares for file transfer (`winrm smb push/pull`) |
|
||||
| 6150 | ⚠️ | ⚠️ | live | psbridge `server.exe` (root jail `E:\psrem`) — **retired 2026-09-10**, superseded by WinRM; may still be listening |
|
||||
| 2222 | ⚠️ | ⚠️ | scan | servecli SSH — **retired; TCP-open but NOT serving SSH** (see below) |
|
||||
|
||||
**Live state at the 2026-08-13 capture** (`8 instances across 4 applications`, all Up; since
|
||||
@@ -392,12 +394,24 @@ nodes, verified Healthy at setup):
|
||||
|
||||
| Application | VQ03 | VQ04 |
|
||||
|---|---|---|
|
||||
| OtOpcUa (cluster `MAIN`) | node-a — **Standby** (`active 503`) | node-b — **Active** (`active 200`) |
|
||||
| OtOpcUa (cluster `MAIN`) | node-a — **Active** since the 2026-09-10 pair restart (was Standby at the 08-13 capture) | node-b — Standby (was Active) |
|
||||
| ScadaBridge `central` | central-a — **Active** | central-b — Standby |
|
||||
| ScadaBridge `site` | site-a — **Active** | site-b — Standby |
|
||||
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
|
||||
| HistorianGateway | Up | Up (since 2026-08-14) |
|
||||
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) |
|
||||
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 12-instance registry) |
|
||||
|
||||
**Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge
|
||||
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,
|
||||
rollback = `F:\MSSQL\BACKUP\ScadaBridge_pre-2b9a7c3c_20260910T121009.bak` on wonder-sql-vq03 because
|
||||
the old binary cannot read the now-encrypted protocol-config row), OtOpcUa `b8b4c18f` (pair stopped
|
||||
together, 72 s; LocalDb replication connected afterwards for the first time; QA's config DB holds **no
|
||||
deployment**, so the address space is empty), MxAccessGateway `4f931e4` Server + `4f931e4b` x86 Worker
|
||||
(rolling, one stop per box), HistorianGateway `d8d890c` (rolling), Overview `818775b3` (code-identical to
|
||||
`846b77f`, not redeployed). Every box keeps `app.bak.20260910T*` next to `app\`, staging under
|
||||
`E:\psrem-stage\<app>-<sha>\`, and a `_DEPLOY-NOTES-20260910T*.txt` per app. Login is enabled on QA
|
||||
(unlike DEV) and was preserved; every appsettings file was restored hash-identical and no service
|
||||
environment was edited. ScadaBridge records: `ScadaBridge/docs/deployment/deployment-records/2026-09-10-*.md`.
|
||||
|
||||
**The 2026-08-14 VQ04 clone, in one paragraph:** MxAccessGw + HistorianGateway + ZbOverview were
|
||||
copied from VQ03 (same `E:\ApiInstall\...` layout, NSSM `AppEnvironmentExtra` config, LocalSystem)
|
||||
@@ -436,10 +450,15 @@ value, and inbound API keys in particular cannot be copied between them ([§9](#
|
||||
|
||||
## 9. Wonder boxes: DEV and QA
|
||||
|
||||
`wonder-app-vd03.zmr.zimmer.com` (SSH `:2222`) is the **DEV** all-in-one box — single-node
|
||||
`wonder-app-vd03.zmr.zimmer.com` (reach it with the `winrm` skill, §2.1) is the **DEV** all-in-one box — single-node
|
||||
ScadaBridge plus OtOpcUa, MxGateway (gRPC `:5120`), HistorianGateway and Overview.
|
||||
`wonder-app-vq03` + `wonder-app-vq04` are the **QA** pair ([§7.1](#71-qa-port-map-vq03-and-vq04)).
|
||||
|
||||
**Both tiers run every repo tip since 2026-09-10** — DEV upgraded 10:45–11:20 (records per app on the
|
||||
box under `E:\ApiInstall\<App>\_DEPLOY-NOTES-20260910T*.txt`; ScadaBridge record in its repo), QA
|
||||
11:48–12:22 (§7.1 "Deployed builds on QA"). DEV runs mxgw's Worker rebuilt on windev (the x86 net48
|
||||
Worker cannot be built on macOS; `ssh windev` is the build box for it).
|
||||
|
||||
Neither install is a scripted in-repo flow — the operational procedures live in ScadaBridge
|
||||
`docs/operations/` (`failover-procedures.md`, `maintenance-procedures.md`,
|
||||
`inbound-api-key-reissue.md`, `troubleshooting-guide.md`). Treat any service/port specifics not in
|
||||
|
||||
@@ -147,7 +147,7 @@ Single page (`/`), `ThemeShell` chassis with its own product name/accent so it r
|
||||
## 9. Acceptance (v1 definition of done)
|
||||
|
||||
- The §4 ScadaBridge site-node health PR is merged and site rows render with full check detail + Active/Standby (no special-cased rows anywhere).
|
||||
- Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login.
|
||||
- Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login. (OtOpcUa one-Active + leader-chip checks still to re-run → #14)
|
||||
- **Cache/staleness proven live:** first page load renders instantly from cache (no poll on request); with auto-refresh paused (or the poller stopped) past `StaleAfterSeconds`, every card flips to Stale, and resumes cleanly.
|
||||
- Boot fails with a clear `ConfigPreflight` message on a malformed registry.
|
||||
- `dotnet test` green: options validator, status derivation (incl. flap damping, staleness marking, leader extraction + disagreement flag, JSON parsing golden tests against captured real payloads from all four apps — with and without `data`), snapshot store, and a bUnit render test of `InstanceCard`.
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# VD03 NAM AD LDAP cutover — plan (NOT executed)
|
||||
|
||||
**Written 2026-08-13. Status: PLAN ONLY.** Swap every auth-bearing service on
|
||||
> **Status lives in Gitea.** Open items from this file are tracked as issues on
|
||||
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
|
||||
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
|
||||
> here; file new work as an issue.
|
||||
|
||||
**Written 2026-08-13. Status: PLAN ONLY.** Execution (VD03, then QA) → #6; consider the Auth `0.4.1` bump first → #5. Swap every auth-bearing service on
|
||||
`wonder-app-vd03` from its current login posture (mostly login-disabled) to the real
|
||||
Zimmer NAM Active Directory, with the service-account password held as a
|
||||
`${secret:}` reference in each app's encrypted Secrets store — never in config.
|
||||
@@ -21,7 +26,7 @@ and the wonder deploy records (memory: `wonder-upgrade-program`).
|
||||
| Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) |
|
||||
|
||||
Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a
|
||||
follow-on once VD03 has soaked; QA is where the 2-node/failover behaviour actually gets
|
||||
follow-on once VD03 has soaked, → #6; QA is where the 2-node/failover behaviour actually gets
|
||||
exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520).
|
||||
|
||||
> ⚠️ **ignitionoee (router): LDAP setup ON HOLD** — explicit decision 2026-08-13. Its role
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
One [GLAuth](https://github.com/glauth/glauth) directory that **all three sister apps use for
|
||||
dev/test auth** — OtOpcUa, MxAccessGateway, ScadaBridge. It runs as a single container on the
|
||||
shared Docker host **`10.100.0.35:3893`** (plaintext LDAP). This is the app-neutral source of
|
||||
shared Docker host **`10.100.0.35:3893`** (plaintext LDAP; LDAPS/StartTLS → #19). This is the app-neutral source of
|
||||
truth; each app just points its `…Ldap:Server` at `10.100.0.35`.
|
||||
|
||||
> Open work on this directory is tracked as Gitea issues on `dohertj2/scadaproj` (e.g. #19 LDAPS).
|
||||
|
||||
> Scope: **dev/test only**. Production uses real corporate AD. See
|
||||
> [`../../docs/plans/2026-06-04-shared-glauth-standardization-design.md`](../../docs/plans/2026-06-04-shared-glauth-standardization-design.md).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user