From 88698ca0425de9ddbd0c13a6533a579c62dcd4cd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 16:13:10 -0500 Subject: [PATCH] docs(deploy): vd03 upgrade-readiness pack b8b4c18f -> master (#793) Read-only ConfigDb preflight (scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql) covering every deploy gate added since the deployed build: VirtualTag/Calculation effective triggers (#783), VT->VT reads and VirtualTagDependencyCycle (#791), newly forbidden script APIs (#792, E2), credential-reference namespace / other-instance / reserved-name rules (#815 #835 #836 #848 #849 #850), MTConnectMultipleDevices, Sql query/write gates (#781), Heidenhain, UNS effective-name collisions and the FOCAS renames/value changes. Verified on a throwaway b8b4c18f-schema database (every check fires on seeded rows) and clean against the docker-dev config. Upgrade notes (docs/deploy/vd03-upgrade-b8b4c18f-to-master.md): the EnableSnapshotIsolation migration and how to apply it by hand, the env-var audit (Security:ApiKeys:Pepper is now required and absent on the box; JWT key validated at start), SDK 2.0.1 / CA KeyUsage, the ScadaBridge-visible changes, swap/rollback and post-deploy checks. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk --- .../deploy/vd03-upgrade-b8b4c18f-to-master.md | 321 +++++++++ ...03-preupgrade-b8b4c18f-configdb-checks.sql | 680 ++++++++++++++++++ 2 files changed, 1001 insertions(+) create mode 100644 docs/deploy/vd03-upgrade-b8b4c18f-to-master.md create mode 100644 scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql diff --git a/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md new file mode 100644 index 000000000..596224410 --- /dev/null +++ b/docs/deploy/vd03-upgrade-b8b4c18f-to-master.md @@ -0,0 +1,321 @@ +# wonder-app-vd03 upgrade: `b8b4c18f` → master `363f0d81` + +Upgrade-readiness notes for the DEV box wonder-app-vd03. The box runs the `OtOpcUaHost` service from +`E:\ApiInstall\OtOpcUa`: one fused admin+driver node with one FOCAS driver (Makino Z-34184). The ConfigDb +is `OtOpcUaConfig` on the box's SQL instance (`docs/deployments/wonder-app-vd03-makino-z-34184.md`). Tracking +issue: Gitea #793. + +**Baseline.** The deployed build is `b8b4c18f` (2026-09-03), not the `e82881c1` recorded in the 2026-08-11 +notes. The evidence: +- All 409 source documents in six deployed PDBs match `b8b4c18f`. +- `deps.json` pins `LocalDb 0.3.4` and `MxGateway.Client 0.2.1`, which only `b8b4c18f` used. + +Everything below is the `b8b4c18f..master` delta. The ConfigDb schema is the same at both commits. The last +migration at both is `20260731162342_AddApiKeyTable`. + +> ⛔ The remote-exec hold on the wonder boxes still applies (root `CLAUDE.md`): no psbridge and no WinRM. The +> owner runs every step on the box. SMB copies and direct SQL logins are allowed. + +## Owner actions, in order + +| # | Action | Blocks | Source | +|---|---|---|---| +| 1 | Run `scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql` against `OtOpcUaConfig`, then fix every `attention` + `blocking = YES` row. | The first deploy on master | §g | +| 2 | **Add `Security__ApiKeys__Pepper`** to the service environment (at least 16 characters, or a `${secret:}` reference). The box does not have this setting. | **Host start**: the host exits with code 1 | §b | +| 3 | Check that `Security__Jwt__SigningKey` is at least 32 UTF-8 bytes. | Host start | §b | +| 4 | Apply the `EnableSnapshotIsolation` migration to `OtOpcUaConfig`. | Every deploy (SQL error 3952) | §a | +| 5 | Check the 22 FOCAS tags for the `MaxRpm` → `MotorSpeedPct` and `AA_1/AA_2` → `AA6/AA7` renames, and for `String` on MACRO/PMC/PARAM addresses. Tell ScadaBridge about the FOCAS value changes. | Client-visible | §d, check 13 | +| 6 | Make the #782 go/hold decision for SDK 2.0.1. The rollout runbook still marks it **HOLD**. | Policy | §c | + +## (a) ConfigDb migration + +- **One migration is in range:** `20261022102459_EnableSnapshotIsolation`, from commit `73e74e27`. + - It runs `ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON` with `suppressTransaction: true`. + - It changes no tables. `Down` is deliberately a no-op. + - Source: `src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20261022102459_EnableSnapshotIsolation.cs`. +- **Why it is required.** `DraftSnapshotFactory.FromConfigDbAsync` now reads the whole draft inside one + `IsolationLevel.Snapshot` transaction. Without the option, the first read fails with SQL error 3952 and the + deploy is rejected. Source: the class remarks in `Configuration/Validation/DraftSnapshotFactory.cs`. +- **The migration is not applied automatically.** The host never migrates the ConfigDb: `Program.cs:129` only + migrates the secrets store. docker-dev runs a one-shot `migrator` container because "production owns schema + changes" (`docker-dev/README.md`, `docker-dev/docker-compose.yml`). The owner applies it by hand, using either + of these: + - **Option 1:** `dotnet ef database update 20261022102459_EnableSnapshotIsolation` from + `src/Core/ZB.MOM.WW.OtOpcUa.Configuration`, with `OTOPCUA_CONFIG_CONNECTION` pointing at `OtOpcUaConfig` + (`DesignTimeDbContextFactory`, `docs/Configuration.md`). + - **Option 2:** run this idempotent script in SSMS. It is the output of + `dotnet ef migrations script 20260731162342_AddApiKeyTable 20261022102459_EnableSnapshotIsolation --idempotent`: + ```sql + IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20261022102459_EnableSnapshotIsolation') + BEGIN ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON; END; + GO + IF NOT EXISTS (SELECT * FROM [__EFMigrationsHistory] WHERE [MigrationId] = N'20261022102459_EnableSnapshotIsolation') + BEGIN INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion]) VALUES (N'20261022102459_EnableSnapshotIsolation', N'10.0.7'); END; + GO + ``` +- **Login and timing.** The login needs `ALTER` on the database. Turning the option on waits for in-flight + transactions but does not need exclusive access, so it can run before the binary swap while the old build is + still up. **The old build is unaffected by it:** it uses no `IsolationLevel` and `ALLOW_SNAPSHOT_ISOLATION` + does not change default reads. A rollback needs no schema undo. + +## (b) Configuration: environment variables and appsettings + +**The 23 service environment variables against master** (names only; values were not inspected): + +| Variable | On master | +|---|---| +| `ASPNETCORE_URLS`, `Cluster__Hostname`, `Cluster__Port`, `Cluster__PublicHostname`, `Cluster__SeedNodes__0`, `ConnectionStrings__ConfigDb`, `DOTNET_ENVIRONMENT`, `LocalDb__Path`, `Security__Auth__DisableLogin`, `Security__DeployApiKey` | Unchanged. `Security:DeployApiKey` now also gates the new `GET /api/deployments/{id}` (`AdminUI/Api/DeployApiEndpoints.cs`). | +| `Cluster__Roles__0/1` + `OTOPCUA_ROLES` | Unchanged (`EffectiveRoles` is already in the baseline). `Cluster:Roles` wins. If the two sets disagree, master logs a Warning. If a role were a `cluster-…` role, #678.6 (`f292cf3b`) now still loads `appsettings.admin-driver.json` (`docs/Configuration.md`). | +| `Secrets__SqlitePath` | Unchanged. The Secrets 0.6.2 absolute, outside-content-root path rule (`b037cc7a`) is **already in the baseline**, and the box boots with it. | +| `Security__Jwt__SigningKey/Issuer/Audience` | **Re-interpreted: now validated at start** (`f72af76e`, `3ddb6876`; `Security/Jwt/JwtOptions.Validate`). When a key is set, it must be at least 32 UTF-8 bytes, and Issuer/Audience must be non-blank, or the host does not start. Bearer validation is now the first-party `AddJwtBearer`, pinned to HS256. | +| `ServerHistorian__Enabled/Endpoint/ApiKey/UseTls` | Same keys. The HistorianGateway client goes 0.3.0 → 1.0.0 (`230cef4e`, historiangw#131). On `UseTls=false`, the cert options are ignored, not refused. A partially valid `CaCertificatePath` bundle now fails client creation. **Not determined:** the oldest gateway build that master works with. If `Enabled=true` on the box, check the box's gateway version first. | +| `GALAXY_MXGW_API_KEY` | Not read by any code directly. A Galaxy config that references `env:GALAXY_MXGW_API_KEY` is **in scope** for #815: `GALAXY_MXGW_` is an allowed Galaxy prefix and `API_KEY` matches `[A-Z0-9_]`. It is also **allowed** under #835/#849 as a shared name, so the deploy gate does not refuse it. It would only collide with a Galaxy driver whose id or name has the env form `API` or `API_KEY` (`DriverCredentialPolicy.cs`, the `GalaxyScope` definition and the comment at line 374). The box has no Galaxy driver. The 2026-08-11 notes say this key was already rejected by the gateway. | +| `Authentication__Ldap__DevStubMode` | **Dead at both commits.** No code reads an `Authentication:` section; the live key is `Security:Ldap:DevStubMode`, which the deployed `appsettings.admin-driver.json` sets to `false`, with `Transport=Ldaps`. Harmless. | + +**New settings master needs that are not in that list:** + +- **`Security__ApiKeys__Pepper`: REQUIRED. It is missing on the box.** + - `ApiKeyOptionsValidator` (`f72af76e`, `Host/Configuration/ApiKeyOptionsValidator.cs`) fails host start on + any admin or driver node when the pepper is blank or shorter than 16 characters. + - The box has no pepper in its environment and none in the deployed `appsettings*.json`. Without it the + upgraded service exits during start-up. Because that happens before Serilog starts, the reason only reaches + stderr and the Event Log. + - On `b8b4c18f` an empty pepper already made minting refuse and verification reject every key + (`ApiKeySecret.cs`, `ApiKeyOpcUaUserAuthenticator.cs`). So no working data-plane API keys exist today, and + choosing a fresh pepper invalidates nothing. Check 14 counts the `ApiKey` rows. + - Supply it as a `${secret:}` reference or an environment variable. Never commit it. +- Everything else is optional and has defaults: + - `OpcUa:CredentialLockout:*` (#833/#844/#845; UserName/API-key only, never Anonymous). + - `Security:ApiKeys` miss budgets, `StoreTimeoutMs` and `StoreCacheSeconds`. + - `DriverWrites:Timeout` (10 s, #777). + - `ServerHistorian:EventReadHorizon` (`00:00:00` = off, #694). + - `ServerHistorian:Gateway:*`. + - `Scripting:Runner`: the default stays `InProcess`. #793 says not to enable `Shadow` or `OutOfProcess` on the + wonder boxes yet. + - Out-of-range values for these log a Warning or fail start only when they are set (`docs/Configuration.md`, + `docs/security.md`). + +**Deployed appsettings vs master.** The deployed files were diffed against the `b8b4c18f` and `master` +copies, with secrets redacted. + +- `appsettings.json`: + - `ContinuousHistorization` still carries the pre-b8b4c18f values (`Capacity 0`, `DrainBatchSize 64`; no + `DrainConcurrency`, `MaxWriteAttempts` or `CaptureWhilePrimaryUnreachable`). Master's in-code defaults fill + the absent keys, and the box's explicit values win. This only matters if continuous historization is + enabled. + - `DriverWrites` and `EventReadHorizon` are absent, so the defaults apply. + - The box uses `Secrets:MasterKey` `FilePath` instead of the shipped `EnvVarName`. That is a site choice and is + unchanged. +- `appsettings.driver.json`: master lowers `Opc.Ua` from Debug to Information. The fused node loads the + `admin-driver` overlay instead, which is unchanged. +- **Keep the box's own `appsettings*.json` across the swap.** Do not take the publisher's copies. + +**Other host-level changes:** +- The publish output now contains `script-worker/` (about 97 MB). It is unused while the runner is `InProcess` + (`docs/ServiceHosting.md` §Script worker processes). +- `/health/ready` gains three checks: + - OPC UA endpoint started; + - script runner (at worst Degraded); + - deploy coordinator (at worst Degraded). + + Source: `Host/Health/HealthEndpoints.cs`. +- No new ports are opened. + +## (c) PKI and OPC UA SDK 2.0.1 (#782) + +- **SDK version.** It moves from 1.5.378.106 to 2.0.1 (`Directory.Packages.props`), passing through + 1.5.378.182 at `2da58d03`. Namespace URIs and NodeIds are unchanged. +- **Certificates and policies.** The PKI store is still the Directory store under `OpcUa:PkiStoreRoot` (default + `pki`, beside the exe). The existing application certificate is reused with the same thumbprint and private + key. Security profiles are unchanged: `None`, `Basic256Sha256Sign` and `Basic256Sha256SignAndEncrypt`. + Sources: `docs/security.md`, `docs/plans/2026-11-10-opcua-sdk-2-upgrade.md`. +- **CA KeyUsage.** #793 says CA certificates must carry the `keyCertSign`/`cRLSign` KeyUsage, or SDK + 1.5.378.182+ rejects them. The upgrade plan (line 159) lists it as a preflight check. **Before the swap, check + every CA in `pki\issuer` and `pki\trusted`, and ScadaBridge's trust of this server's chain.** A self-signed + application certificate is not a CA and is not affected. The repo states no further KeyUsage rule. +- **Release status.** `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` still marks the release **HOLD**. Its open + gates are the memory soak (#799), Windows service validation (#800) and live consumer testing (#801). vd03 + would be the first Windows host to run SDK 2. Record the go/hold decision in #782. +- **ScadaBridge.** The adapter passed its 18 tests on `None` + API key. SignAndEncrypt from ScadaBridge is still + untested because ScadaBridge has no client certificate yet (ScadaBridge#121; the issue predates this upgrade). + +## (d) What ScadaBridge will see (b8b4c18f..master) + +The detail is in `../scadaproj/.claude/skills/sister-project-history/otopcua.md`; the commits are cited below. + +**On this FOCAS box:** +- **Macros.** Macros are now decoded at the right offset (`665ecd55`), so `parts-count`/`parts-required` + (`MACRO:3901`/`3902`) change magnitude. A vacant macro reads `BadNoData`. A fractional value on an integer tag + reads `BadTypeMismatch`. +- **FixedTree.** + - Positions are scaled by their envelope (`69e0a622`; for example 2801574 → 280.1574). + - Servo/spindle `Load` is now a real percent, and spindle Load is Float64 (`1dba46bd`). + - `Spindle/*/MaxRpm` is renamed `MotorSpeedPct` (`1dba46bd`). + - `Axes/AA_1`/`AA_2` are renamed `AA6`/`AA7` (`9460da51`). + - Unreported program fields read `BadNoData` instead of a Good 0 (`26b512ad`). + - A CNC refusal makes only that tag Bad and no longer Degrades the driver (`2724d7da`, #740). + - `String` on a PMC/PARAM/MACRO address is refused, and the tag is skipped (`cc140593`). + - New nodes: a `Status/` subtree (#257) and optional `ALARM:` conditions (#647). +- **ServiceLevel** drops to 0 if the OPC UA endpoint fails to start (#762). +- **Virtual tags.** A disabled virtual tag reads `BadOutOfService` (#787). VT→VT reads now produce values (#791). +- **Logins and API keys.** + - Credential lockout now covers UserName/API-key logins only, never Anonymous (#833/#844/#845). + - Unknown API keys are rate-limited per client (#751). + - Expired or disabled keys are refused for writes and acks (#563). +- **ACL.** Event and subscription filtering by ACL changes only if `Security:Acl:Enabled` is on (#586/#588, + #797/#808, #826). Whether it is on for vd03 was not determined. +- **Address-space changes.** Surgically added raw nodes are now announced with model-change events (#599). + Native condition state survives a rebuild (#641), and the native alarm feed survives a deploy (#662). +- **Alarms and history.** Each method transition emits exactly one event (#587). Overload deferral is per + condition (#838/#854/#855). Event history now carries the event type and condition name (#746). Processed + reads with no historian return `BadAggregateNotSupported` (#682). + +**Not reachable here (no such driver):** +- Galaxy: OPC DA quality grading of status rows (#866, `363f0d81`), first-value race (#859), acks wait for + confirmation (#869). +- MTConnect: blank string reads Good `""` (#824). +- OpcUaClient: host-name check (#816). +- Heidenhain: new driver (#803). +- AbCip: the native transport is the default. + +## (e) Install, swap and rollback + +The source is `docs/plans/2026-11-10-opcua-sdk-2-rollout.md` (§Sequence, §Rollback). `docs/ServiceHosting.md` +§Install/Uninstall describes a fresh install only. + +1. **Publish** a fresh directory: + `dotnet publish src/Server/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj -c Release -r win-x64 --self-contained true -o `. + Hash it into a manifest stored outside the directory, then copy it to the box over SMB. +2. **Back up** these: + - the whole install, with the service stopped: `robocopy E:\ApiInstall\OtOpcUa /E /COPYALL /DCOPY:DAT`. + This covers `pki\`, `appsettings*.json`, `data\` (LocalDb) and `logs\`; + - the secrets SQLite file at `Secrets__SqlitePath` (it is outside the install), and its master-key file; + - `reg export HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaHost`. **It holds secrets, so protect the file;** + - `BACKUP DATABASE OtOpcUaConfig`. +3. **Add the pepper** (action 2) and check the JWT key length (action 3) on the service key. Apply the migration + (§a). +4. **Swap the directories:** + - `Stop-Service OtOpcUaHost` and wait for Stopped. + - `Move-Item` the live install to a fresh sibling `$PreviousInstall`, then `Move-Item` the candidate into + `E:\ApiInstall\OtOpcUa`. + - Copy back the box's `appsettings*.json`, `pki\` and `data\`. + - `Start-Service` and wait for Running. +5. **Never run `Install-Services.ps1`, `Uninstall-Services.ps1` or `Refresh-Services.ps1` here.** + - `sc delete` wipes the 23 environment variables. + - `Install-Services.ps1` rewrites the `Environment` value with only 2–3 entries. + - `Refresh-Services.ps1` assumes NSSM and `C:\publish`. + - The folder swap leaves the registry key untouched. +6. **Rollback.** + - Stop the service and move the candidate to a quarantine path. + - Restore `$PreviousInstall` (or the robocopy backup), including its `appsettings*.json`, `pki\` and `data\`. + - Check the certificate thumbprint, then start the service. + - Clients reconnect. Do not replay writes or acks. + + No schema undo is needed (§a). Remove the pepper only if it got in the way; `b8b4c18f` ignores it. The + baseline already reads gzip artifacts (`9dbd5e96` predates `b8b4c18f`), so artifacts written by master stay + readable after a rollback. + + **Not determined:** whether the `b8b4c18f` build reads a LocalDb or alarm-outbox file after master has + written to it. Restore `data\` from the backup. + +## (f) Post-deploy checks + +- **Boot log** `logs\otopcua-YYYYMMDD.log`. Read its contents; the listed size shows 0 while the service holds + it open. + - Expect `OtOpcUa.Host starting with roles=[admin,driver]`, with no roles-disagreement warning. + - About 15 s later expect `Credential-reference preflight (lmxopcua#815): every stored and deployed reference is namespaced.` + This comes from `CredentialReferencePreflightService` in `AdminUI/Deployments/CredentialReferencePreflight.cs`. + - If the service stops immediately, check the Windows Event Log and stderr for the pepper/JWT `FATAL:` message + (`Program.cs`, #618). +- **Health**, at `http://wonder-app-vd03.zmr.zimmer.com:9000`: + - `/healthz`: process up. + - `/health/ready`: ConfigDb, Akka Up, OPC UA endpoint started, script runner, deploy coordinator. + - `/health/active`. + + All three should return 200 on this single node (`Host/Health/HealthEndpoints.cs`, `docs/ServiceHosting.md` + §Health endpoints). +- **`/raw` credential banner (#834, warn-only by owner decision).** It should be absent. FOCAS has no credential + fields (`docs/security.md` §"Credential references are namespaced per driver"). A banner listing rows means a + stored, deployed or running out-of-namespace reference that the next deploy will refuse. +- **`/hosts`.** The FOCAS driver row should be Healthy, with the per-node chips (#847/#851). Galaxy host statuses + do not apply here (no Galaxy driver). +- **Deploy once from the AdminUI**, then check `/fleet` and `/deployments`. + - Expect `Applied`, not `Applied (degraded)` (#601, `docs/Redundancy.md`). Node rows show `Applying` until the + address-space outcome is reported (#868). + - A 3952 error means §a was skipped. + - A `VirtualTagNoEffectiveTrigger`, `CredentialReferenceOutOfScope` or similar rejection means a preflight row + was missed. +- **OPC UA** at `opc.tcp://wonder-app-vd03.zmr.zimmer.com:4840/OtOpcUa`: + - The server certificate thumbprint is unchanged (`A873DF51…`) and ServiceLevel is non-zero. + - Browse the raw and UNS trees and read `parts-count` with the Client CLI (`docs/Client.CLI.md`). Expect the new + magnitude. + - From ScadaBridge: it reconnects, values are Good, and any `MaxRpm`/`AA_x`/`Load` bindings have been + re-pointed. + +## (g) Preflight SQL: `scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql` + +**What it is.** The script is read-only: it uses SELECT plus table variables. It is written against the +`b8b4c18f` schema and needs SQL Server 2016+ at compatibility level 130 or higher. + +**How to run it:** +`sqlcmd -S -d OtOpcUaConfig -E -i vd03-preupgrade-b8b4c18f-configdb-checks.sql -W -s "|" -o preflight.txt` + +**Output.** +- Each check produces a header row (`ok` / `attention` / `info`, the row count, whether it blocks the deploy, + and its source), followed by its detail rows. +- Credential values are never printed: reference names are shown, and literals are redacted. + +| # | Check | Blocks deploy? | Enforced on master by | Source | +|---|---|---|---|---| +| 0 | Environment: database, compatibility level, last migration, snapshot-isolation state | — | — | — | +| 1 | `VirtualTagNoEffectiveTrigger` | yes | `DraftValidator.ValidateVirtualTagTriggers` | #783 `d9b95dcf` | +| 2 | `CalculationNoEffectiveTrigger` | yes | `DraftValidator.ValidateCalculationTags` rule 3 | `4d3fc9c8` | +| 3 | Scripts that read a VirtualTag: they now resolve and start producing values | no (behaviour) | `VirtualTagHostActor` via dependency mux | #791 `000d7f55` | +| 4 | `VirtualTagDependencyCycle` (approximate: bounded search, 25 hops) | yes | `DraftValidator.ValidateVirtualTagDependencyCycles` | #791 `000d7f55` | +| 5 | Newly forbidden script APIs (#793 list plus E2's `System.Threading`, `Type` reflection, `RuntimeHelpers`) | no (compile failure at runtime → node Bad) | `Core.Scripting/ForbiddenTypeAnalyzer.cs` | #792 `0e96dd08`, E2 `2e916031` | +| 6 | `CredentialReferenceOutOfScope`, plus the #836 `UserCertificatePath` rule | yes | `CredentialReferenceDeployChecks`, `DriverCredentialPolicy` | #815 `a224fd63`, #836 `63331b04`, #850 `3dbfe6ec` | +| 7 | `CredentialReferenceOtherInstance` (`secret:` segment / `env:` part names another same-type driver) | yes | `DriverCredentialPolicy.DescribeOtherInstanceProblems` | #835 `63331b04`, #849 `4a6229e4`/`d68dff09` | +| 8 | Reserved driver names (`drv`, `DRV-<12 hex>`) | no (save/rename refused) | `DriverCredentialPolicy.IsReservedInstanceName` | #848/#849 `d08e59ee`/`d68dff09` | +| 9 | `MTConnectMultipleDevices` | yes | `DraftValidator.ValidateMTConnectSingleDevice` | #628 `dcc827ca` | +| 10 | Sql: `SqlQueryNotDeclared` (10a), the `write` block (10b, review), writable mismatch (10c, warning) | 10a/10b yes | `SqlTagDeployChecks` | #781 `241318e7`, `f5c30ed0` | +| 11 | Heidenhain drivers present (expected none; the type is new) | yes | `HeidenhainDeployChecks` | #803 `2b45694f` | +| 12 | UNS effective-name collisions (an unchanged rule, re-checked) | yes | `DraftValidator.ValidateUnsEffectiveLeafUniqueness` | — | +| 13 | FOCAS renames, `String` refusals and value changes | no (deploy warning; client-visible) | `FocasTagDefinitionFactory`, FOCAS wire client | `1dba46bd`, `9460da51`, `cc140593`, `69e0a622`, `665ecd55`, `26b512ad` | +| 14 | Inventory: drivers, tags, VTs, scripts, API keys | — | — | — | + +**Approximations.** T-SQL cannot reproduce the C# regexes or `System.Text.Json` exactly; the script header lists +every approximation. In short: +- `ctx.GetTag("…")` literals are extracted after whitespace normalisation. +- Cycles are found by a bounded path search. +- `ISJSON` is stricter than `System.Text.Json` (comments, trailing commas). +- The #850 merged driver+device path check is shown as `review`. +- Check 5 is a text search, so its `review` patterns can be false positives (for example + `JsonElement.GetProperty`). + +Some branches cannot occur under the database's own CHECK constraints but are kept for completeness. For +example, `CK` on `VirtualTag` makes `ChangeTriggered = 0` without a timer impossible, so only the "no literal +read" branch of check 1 can fire. + +**Not covered.** `EquipmentTagConfigInspector` findings are warnings only (`Deployment:TagConfigValidationMode=Warn` +by default, `AdminOperationsActor`). Cluster topology, ACL scope and `EquipReferenceUnresolved` are unchanged +since the baseline. + +**How it was verified (2026-11-11):** +- A throwaway database `otopcua_vd03preflight_` was built on the local docker-dev SQL Server 2022 + (`localhost,14330`) at compatibility level 130. It was migrated with `dotnet ef database update + 20260731162342_AddApiKeyTable`, which is the `b8b4c18f` schema. +- It was seeded with one or more rows that trigger each check. Every check fired as expected, and check 11 was + the expected `ok`. The database was then dropped. +- The script also ran clean against the docker-dev `OtOpcUa` database (master schema, 53 drivers). Every gate + check was `ok` there, which matches master's own gate accepting that config. + +## Not determined + +- Whether the box's `ServerHistorian__Enabled` is true, and if so, which HistorianGateway build it points at + (client 1.0.0 compatibility). +- Whether the box's JWT signing key is at least 32 bytes. +- Whether `Security:Acl:Enabled` is on. +- ScadaBridge's security mode against vd03. +- Whether `b8b4c18f` can reopen LocalDb or outbox files after master has written them; restore them from backup + on rollback. +- The current #782 go/hold state. The repo runbook says HOLD. diff --git a/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql new file mode 100644 index 000000000..ff572fef9 --- /dev/null +++ b/scripts/preflight/vd03-preupgrade-b8b4c18f-configdb-checks.sql @@ -0,0 +1,680 @@ +/* ===================================================================================================== + vd03-preupgrade-b8b4c18f-configdb-checks.sql + + Pre-upgrade ConfigDb checks for wonder-app-vd03: OtOpcUa b8b4c18f (2026-09-03, the build actually deployed — + identified by PDB source-hash + deps.json package versions) -> master 363f0d81. + Gitea lmxopcua#793. Companion notes: docs/deploy/vd03-upgrade-b8b4c18f-to-master.md + + STRICTLY READ-ONLY. SELECT statements only, plus INSERTs into local TABLE VARIABLES (session-scoped, never + written to this database). No DDL, no DML against any database table, no temp tables, no transactions. + + Run it against the ConfigDb while the box is STILL on b8b4c18f, i.e. before the EnableSnapshotIsolation + migration and before the new binaries start: + + sqlcmd -S WONDER-APP-VD03 -d OtOpcUaConfig -E -i vd03-preupgrade-b8b4c18f-configdb-checks.sql -W -s "|" -o preflight.txt + (or open it in SSMS with the OtOpcUaConfig database selected and run it; use "Results to Text" for one file) + + The database name on vd03 is OtOpcUaConfig (docs/deployments/wonder-app-vd03-makino-z-34184.md); the + script does not hard-code it (no USE), so select the right database first. Check 0 prints which one you hit. + + Requirements: SQL Server 2016+ AND database compatibility level >= 130 (OPENJSON). Check 0 reports both. + + Schema: written against the b8b4c18f EF model (last migration 20260731162342_AddApiKeyTable — identical to + e82881c1; no migration landed between them). master adds only 20261022102459_EnableSnapshotIsolation (no + table/column change), so the script also runs unchanged after the upgrade. Every rule checked here is new in + b8b4c18f..master (DraftValidator / ForbiddenTypeAnalyzer were untouched between e82881c1 and b8b4c18f). + + OUTPUT: every check emits two result sets + (1) a one-row header: check id, title, status ('ok' | 'attention' | 'info'), row count, blocking?, source + (2) the detail rows (empty when ok). + 'attention' + blocking = YES means master's deploy gate (DraftValidator / AdminOperationsActor) will REFUSE the + next deploy until fixed. blocking = NO means a behaviour change or an authoring/runtime effect, not a refusal. + + Secrets: no credential VALUE is ever selected. Credential fields are reported as a classification plus the + reference NAME (secret:/env: names are not secrets); literals print as ''. + + Approximations (the C# rules use Roslyn-free regexes and System.Text.Json; T-SQL cannot reproduce them + exactly — every approximation errs toward listing MORE rows, never fewer, except where noted): + * ctx.GetTag("...") literal extraction normalises whitespace first (CR/LF/TAB -> space, runs collapsed, + spaces around '.', '(' and the quotes removed). A path literal containing a double space would be + mangled; a GetTag inside a comment or string is counted (the C# regex counts it too). + * Cycle detection (check 4) is a bounded path search (depth <= 25) over the same graph DraftValidator + builds; it can miss a cycle longer than 25 hops and reports each cycle once per VirtualTag member. + * JSON: ISJSON/OPENJSON reject comments and trailing commas that System.Text.Json (with the policy's + options) accepts, so such a blob shows as 'config is not valid JSON' here but may pass the gate. + * The #850 merged driver+device check is approximated (see check 6). + ===================================================================================================== */ + +SET NOCOUNT ON; + +/* --------------------------------------------------------------------------------------------- + Shared working sets (table variables) + --------------------------------------------------------------------------------------------- */ + +DECLARE @Num TABLE (n int PRIMARY KEY); +WITH x AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM x WHERE n < 256) +INSERT @Num (n) SELECT n FROM x OPTION (MAXRECURSION 256); + +-- Scripts with a whitespace-normalised copy of the source for ctx.GetTag("...") extraction. +DECLARE @Script TABLE (ScriptId nvarchar(64) PRIMARY KEY, Name nvarchar(128), Src nvarchar(max) COLLATE Latin1_General_BIN2); +INSERT @Script (ScriptId, Name, Src) +SELECT s.ScriptId, s.Name, + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( + REPLACE(REPLACE(REPLACE(s.SourceCode, CHAR(13), ' '), CHAR(10), ' '), CHAR(9), ' ') + , ' ', ' '), ' ', ' '), ' ', ' '), ' ', ' '), ' ', ' ') + , ' .', '.'), '. ', '.'), 'GetTag (', 'GetTag('), 'GetTag( ', 'GetTag('), '" )', '")'), ' )', ')'), '( "', '("'), '( ', '(') +FROM dbo.Script s +WHERE s.ScriptId IS NOT NULL; + +-- Literal ctx.GetTag("") reads (EquipmentScriptPaths.ExtractDependencyRefs: ctx\s*\.\s*GetTag\s*\(\s*"([^"]+)"\s*\)). +DECLARE @Ref TABLE (ScriptId nvarchar(64), Ref nvarchar(4000) COLLATE Latin1_General_BIN2); +WITH occ AS ( + SELECT s.ScriptId, CHARINDEX(N'ctx.GetTag("', s.Src) AS p + FROM @Script s WHERE CHARINDEX(N'ctx.GetTag("', s.Src) > 0 + UNION ALL + SELECT o.ScriptId, CHARINDEX(N'ctx.GetTag("', s.Src, o.p + 1) + FROM occ o INNER JOIN @Script s ON s.ScriptId = o.ScriptId + WHERE CHARINDEX(N'ctx.GetTag("', s.Src, o.p + 1) > 0 +) +INSERT @Ref (ScriptId, Ref) +SELECT o.ScriptId, SUBSTRING(s.Src, o.p + 12, e.q - (o.p + 12)) +FROM occ o +INNER JOIN @Script s ON s.ScriptId = o.ScriptId +CROSS APPLY (SELECT CHARINDEX(N'"', s.Src, o.p + 12) AS q) e +WHERE e.q > o.p + 12 AND SUBSTRING(s.Src, e.q + 1, 1) = N')' +OPTION (MAXRECURSION 0); + +-- RawPath of every raw tag: //// (RawPathResolver). +DECLARE @FolderPath TABLE (RawFolderId nvarchar(64) PRIMARY KEY, Path nvarchar(4000)); +WITH f AS ( + SELECT RawFolderId, CAST(Name AS nvarchar(4000)) AS Path, 0 AS d FROM dbo.RawFolder WHERE ParentRawFolderId IS NULL AND RawFolderId IS NOT NULL + UNION ALL + SELECT c.RawFolderId, CAST(f.Path + N'/' + c.Name AS nvarchar(4000)), f.d + 1 + FROM dbo.RawFolder c INNER JOIN f ON c.ParentRawFolderId = f.RawFolderId WHERE f.d < 256 +) +INSERT @FolderPath SELECT RawFolderId, MIN(Path) FROM f GROUP BY RawFolderId OPTION (MAXRECURSION 300); + +DECLARE @GroupPath TABLE (TagGroupId nvarchar(64) PRIMARY KEY, Path nvarchar(4000)); +WITH g AS ( + SELECT TagGroupId, CAST(Name AS nvarchar(4000)) AS Path, 0 AS d FROM dbo.TagGroup WHERE ParentTagGroupId IS NULL AND TagGroupId IS NOT NULL + UNION ALL + SELECT c.TagGroupId, CAST(g.Path + N'/' + c.Name AS nvarchar(4000)), g.d + 1 + FROM dbo.TagGroup c INNER JOIN g ON c.ParentTagGroupId = g.TagGroupId WHERE g.d < 256 +) +INSERT @GroupPath SELECT TagGroupId, MIN(Path) FROM g GROUP BY TagGroupId OPTION (MAXRECURSION 300); + +DECLARE @RawTag TABLE ( + TagId nvarchar(64), Name nvarchar(128), + DeviceId nvarchar(64), DriverInstanceId nvarchar(64), + DriverName nvarchar(128), DriverType nvarchar(32) COLLATE Latin1_General_BIN2, + AccessLevel nvarchar(16), DataType nvarchar(32), TagConfig nvarchar(max), RawPath nvarchar(4000) COLLATE Latin1_General_BIN2); +INSERT @RawTag +SELECT t.TagId, t.Name, t.DeviceId, di.DriverInstanceId, di.Name, di.DriverType, t.AccessLevel, t.DataType, t.TagConfig, + CASE WHEN di.RawFolderId IS NOT NULL AND fp.Path IS NULL THEN NULL -- unresolvable folder: no RawPath + WHEN t.TagGroupId IS NOT NULL AND gp.Path IS NULL THEN NULL -- unresolvable group: no RawPath + ELSE ISNULL(fp.Path + N'/', N'') + di.Name + N'/' + dv.Name + N'/' + ISNULL(gp.Path + N'/', N'') + t.Name END +FROM dbo.Tag t +INNER JOIN dbo.Device dv ON dv.DeviceId = t.DeviceId +INNER JOIN dbo.DriverInstance di ON di.DriverInstanceId = dv.DriverInstanceId +LEFT JOIN @FolderPath fp ON fp.RawFolderId = di.RawFolderId +LEFT JOIN @GroupPath gp ON gp.TagGroupId = t.TagGroupId; + +-- Calculation tags: RawPath + scriptId (TagConfig.scriptId, string) + trigger fields. +DECLARE @Calc TABLE (TagId nvarchar(64), RawPath nvarchar(4000) COLLATE Latin1_General_BIN2, ScriptId nvarchar(4000), + ChangeTriggered bit, TimerIntervalMs bigint NULL); +INSERT @Calc +SELECT r.TagId, r.RawPath, + (SELECT TOP 1 j.[value] FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j WHERE j.[key] COLLATE Latin1_General_BIN2 = N'scriptId' AND j.[type] = 1), + ISNULL((SELECT TOP 1 CASE j.[value] WHEN N'false' THEN 0 ELSE 1 END FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j + WHERE j.[key] COLLATE Latin1_General_BIN2 = N'changeTriggered' AND j.[type] = 3), 1), + (SELECT TOP 1 TRY_CAST(j.[value] AS bigint) FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) j + WHERE j.[key] COLLATE Latin1_General_BIN2 = N'timerIntervalMs' AND j.[type] = 2) +FROM @RawTag r WHERE r.DriverType = N'Calculation'; + +-- UNS reference effective names -> backing RawPath (EquipmentReferenceMap: DisplayNameOverride else raw tag Name). +DECLARE @EquipRef TABLE (EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, EffName nvarchar(128) COLLATE Latin1_General_BIN2, RawPath nvarchar(4000) COLLATE Latin1_General_BIN2); +INSERT @EquipRef +SELECT u.EquipmentId, CASE WHEN ISNULL(u.DisplayNameOverride, N'') = N'' THEN r.Name ELSE u.DisplayNameOverride END, r.RawPath +FROM dbo.UnsTagReference u INNER JOIN @RawTag r ON r.TagId = u.TagId; + +-- Enabled VirtualTags and their mux key {EquipmentId}/{Name}. +DECLARE @VT TABLE (VirtualTagId nvarchar(64), EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, Name nvarchar(128) COLLATE Latin1_General_BIN2, + VtKey nvarchar(300) COLLATE Latin1_General_BIN2, ScriptId nvarchar(64), + ChangeTriggered bit, TimerIntervalMs int NULL, Enabled bit); +INSERT @VT SELECT v.VirtualTagId, v.EquipmentId, v.Name, v.EquipmentId + N'/' + v.Name, v.ScriptId, v.ChangeTriggered, v.TimerIntervalMs, v.Enabled +FROM dbo.VirtualTag v; + +-- Which entity uses which script (for reporting). +DECLARE @ScriptUse TABLE (ScriptId nvarchar(64), UsedByKind nvarchar(20), UsedById nvarchar(64), UsedByEnabled bit, EquipmentId nvarchar(64) NULL); +INSERT @ScriptUse SELECT ScriptId, N'VirtualTag', VirtualTagId, Enabled, EquipmentId FROM @VT; +INSERT @ScriptUse SELECT a.PredicateScriptId, N'ScriptedAlarm', a.ScriptedAlarmId, a.Enabled, a.EquipmentId FROM dbo.ScriptedAlarm a; +INSERT @ScriptUse SELECT c.ScriptId, N'CalculationTag', c.TagId, 1, NULL FROM @Calc c WHERE c.ScriptId IS NOT NULL; + +/* --------------------------------------------------------------------------------------------- + Check 0 — environment + --------------------------------------------------------------------------------------------- */ +DECLARE @lastMig nvarchar(150) = (SELECT TOP 1 MigrationId FROM dbo.__EFMigrationsHistory ORDER BY MigrationId DESC); +DECLARE @compat int = (SELECT compatibility_level FROM sys.databases WHERE database_id = DB_ID()); +DECLARE @snap nvarchar(60) = (SELECT snapshot_isolation_state_desc FROM sys.databases WHERE database_id = DB_ID()); +SELECT N'0' AS check_id, N'Environment' AS title, + CASE WHEN @lastMig = N'20260731162342_AddApiKeyTable' AND @compat >= 130 THEN N'ok' ELSE N'attention' END AS status, + 1 AS row_count, N'NO' AS blocking, + N'expects last migration 20260731162342_AddApiKeyTable (b8b4c18f) and compat >= 130' AS source; +SELECT DB_NAME() AS database_name, @@SERVERNAME AS server_name, CAST(SERVERPROPERTY('ProductVersion') AS nvarchar(40)) AS sql_version, + @compat AS compatibility_level, @lastMig AS last_applied_migration, @snap AS snapshot_isolation_state, + N'master needs migration 20261022102459_EnableSnapshotIsolation (ALLOW_SNAPSHOT_ISOLATION ON) before the first deploy' AS note; + +/* --------------------------------------------------------------------------------------------- + Check 1 — #783 VirtualTagNoEffectiveTrigger (BLOCKING) + master: DraftValidator.ValidateVirtualTagTriggers (d9b95dcf). Enabled VT, TimerIntervalMs null/<=0, and either + ChangeTriggered = 0, or ChangeTriggered = 1 with a resolvable script that has NO literal ctx.GetTag read. + --------------------------------------------------------------------------------------------- */ +DECLARE @c1 TABLE (VirtualTagId nvarchar(64), EquipmentId nvarchar(64), Name nvarchar(128), ScriptId nvarchar(64), ChangeTriggered bit, TimerIntervalMs int NULL, literal_reads int, reason nvarchar(200)); +INSERT @c1 +SELECT v.VirtualTagId, v.EquipmentId, v.Name, v.ScriptId, v.ChangeTriggered, v.TimerIntervalMs, + (SELECT COUNT(*) FROM @Ref r WHERE r.ScriptId = v.ScriptId), + CASE WHEN v.ChangeTriggered = 0 THEN N'ChangeTriggered=0 and no TimerIntervalMs' + ELSE N'change-triggered but script has no literal ctx.GetTag read, and no TimerIntervalMs' END +FROM @VT v +WHERE v.Enabled = 1 AND ISNULL(v.TimerIntervalMs, 0) <= 0 + AND (v.ChangeTriggered = 0 + OR (EXISTS (SELECT 1 FROM @Script s WHERE s.ScriptId = v.ScriptId) AND NOT EXISTS (SELECT 1 FROM @Ref r WHERE r.ScriptId = v.ScriptId))); +SELECT N'1' AS check_id, N'#783 VirtualTagNoEffectiveTrigger' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateVirtualTagTriggers; commit d9b95dcf' AS source FROM @c1; +SELECT * FROM @c1 ORDER BY EquipmentId, Name; + +/* --------------------------------------------------------------------------------------------- + Check 2 — CalculationNoEffectiveTrigger (BLOCKING) + master: DraftValidator.ValidateCalculationTags rule 3 (4d3fc9c8, E4/R07:U5). Calc tag with no timerIntervalMs > 0 + and either changeTriggered=false, or change-triggered with a resolvable script that has no literal ctx.GetTag read. + --------------------------------------------------------------------------------------------- */ +DECLARE @c2 TABLE (TagId nvarchar(64), RawPath nvarchar(4000), ScriptId nvarchar(4000), changeTriggered bit, timerIntervalMs bigint NULL, reason nvarchar(200)); +INSERT @c2 +SELECT c.TagId, c.RawPath, c.ScriptId, c.ChangeTriggered, c.TimerIntervalMs, + CASE WHEN c.ChangeTriggered = 0 THEN N'changeTriggered=false and no timerIntervalMs' + ELSE N'change-triggered but script has no literal ctx.GetTag read, and no timerIntervalMs' END +FROM @Calc c +WHERE ISNULL(c.TimerIntervalMs, 0) <= 0 + AND (c.ChangeTriggered = 0 + OR (EXISTS (SELECT 1 FROM @Script s WHERE s.ScriptId = c.ScriptId) AND NOT EXISTS (SELECT 1 FROM @Ref r WHERE r.ScriptId = c.ScriptId))); +SELECT N'2' AS check_id, N'CalculationNoEffectiveTrigger' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateCalculationTags (rule 3); commit 4d3fc9c8' AS source FROM @c2; +SELECT * FROM @c2 ORDER BY RawPath; + +/* --------------------------------------------------------------------------------------------- + Check 3 — #791 scripts that read a VirtualTag (BEHAVIOUR CHANGE, not blocking) + master routes VirtualTag outputs through the dependency mux (000d7f55), so a ctx.GetTag("/") + read now RESOLVES and starts producing values / firing where it produced none before. A bare VT name (no '/') + never resolved and still does not; it is listed so it can be fixed. {{equip}}/ resolves references only — + naming a VT there is EquipReferenceUnresolved (pre-existing error; master's message now hints the key form). + --------------------------------------------------------------------------------------------- */ +DECLARE @c3 TABLE (ScriptId nvarchar(64), ScriptName nvarchar(128), used_by nvarchar(max), read_literal nvarchar(4000), match_kind nvarchar(80), target_vt nvarchar(64), target_enabled bit); +INSERT @c3 +SELECT s.ScriptId, s.Name, + STUFF((SELECT N', ' + u.UsedByKind + N':' + u.UsedById + CASE WHEN u.UsedByEnabled = 0 THEN N'(disabled)' ELSE N'' END + FROM @ScriptUse u WHERE u.ScriptId = s.ScriptId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N''), + r.Ref, + CASE WHEN r.Ref = v.VtKey THEN N'VT key {EquipmentId}/{Name}: now resolves (#791)' + ELSE N'bare VT name: never resolved, still does not' END, + v.VirtualTagId, v.Enabled +FROM @Ref r +INNER JOIN @Script s ON s.ScriptId = r.ScriptId +INNER JOIN @VT v ON r.Ref = v.VtKey OR (r.Ref = v.Name AND CHARINDEX(N'/', r.Ref) = 0); +SELECT N'3' AS check_id, N'#791 scripts reading a VirtualTag (VT->VT now works)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'NO' AS blocking, N'VirtualTag outputs via dependency mux; commit 000d7f55' AS source FROM @c3; +SELECT * FROM @c3 ORDER BY ScriptId, read_literal; + +/* --------------------------------------------------------------------------------------------- + Check 4 — #791 VirtualTagDependencyCycle (BLOCKING) — APPROXIMATE + master: DraftValidator.ValidateVirtualTagDependencyCycles (000d7f55). Nodes = enabled VT keys + every Calculation + tag RawPath; edges = literal ctx.GetTag reads that are themselves nodes (VT reads after {{equip}}/ + substitution through the equipment's references). Cycles made only of Calculation tags are the pre-existing + CalculationDependencyCycle rule and are not listed. Bounded search (25 hops). + --------------------------------------------------------------------------------------------- */ +DECLARE @Node TABLE (NodeKey nvarchar(4000) COLLATE Latin1_General_BIN2, IsVt bit); +INSERT @Node SELECT VtKey, 1 FROM @VT WHERE Enabled = 1; +INSERT @Node SELECT RawPath, 0 FROM @Calc WHERE RawPath IS NOT NULL AND RawPath NOT IN (SELECT NodeKey FROM @Node); + +DECLARE @Edge TABLE (FromKey nvarchar(4000) COLLATE Latin1_General_BIN2, ToKey nvarchar(4000) COLLATE Latin1_General_BIN2); +-- VT edges ({{equip}}/ substituted to the backing RawPath when the equipment has that reference). +INSERT @Edge +SELECT DISTINCT v.VtKey, COALESCE(er.RawPath, r.Ref) +FROM @VT v +INNER JOIN @Ref r ON r.ScriptId = v.ScriptId +LEFT JOIN @EquipRef er ON er.EquipmentId = v.EquipmentId AND LEFT(r.Ref, 10) = N'{{equip}}/' AND er.EffName = SUBSTRING(r.Ref, 11, 4000) +WHERE v.Enabled = 1 AND COALESCE(er.RawPath, r.Ref) IN (SELECT NodeKey FROM @Node); +-- Calculation edges (no {{equip}} substitution for calc scripts). +INSERT @Edge +SELECT DISTINCT c.RawPath, r.Ref +FROM @Calc c INNER JOIN @Ref r ON r.ScriptId = c.ScriptId +WHERE c.RawPath IS NOT NULL AND r.Ref IN (SELECT NodeKey FROM @Node); + +DECLARE @c4 TABLE (start_vt_key nvarchar(4000), cycle_path nvarchar(max), hops int); +WITH walk AS ( + SELECT e.FromKey AS StartKey, e.ToKey AS CurKey, CAST(e.FromKey + N' -> ' + e.ToKey AS nvarchar(max)) AS Path, + CAST(N'|' + e.FromKey + N'|' AS nvarchar(max)) AS Seen, 1 AS hops + FROM @Edge e INNER JOIN @Node n ON n.NodeKey = e.FromKey AND n.IsVt = 1 + UNION ALL + SELECT w.StartKey, e.ToKey, w.Path + N' -> ' + e.ToKey, w.Seen + w.CurKey + N'|', w.hops + 1 + FROM walk w INNER JOIN @Edge e ON e.FromKey = w.CurKey + WHERE w.hops < 25 AND w.CurKey <> w.StartKey AND CHARINDEX(N'|' + w.CurKey + N'|', w.Seen) = 0 +) +INSERT @c4 SELECT StartKey, Path, hops FROM walk WHERE CurKey = StartKey OPTION (MAXRECURSION 30); +SELECT N'4' AS check_id, N'#791 VirtualTagDependencyCycle (approximate)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateVirtualTagDependencyCycles; commit 000d7f55' AS source FROM @c4; +SELECT * FROM @c4 ORDER BY start_vt_key; + +/* --------------------------------------------------------------------------------------------- + Check 5 — #792 + E2 newly forbidden script APIs (RUNTIME compile failure, not a deploy refusal) + master: Core.Scripting/ForbiddenTypeAnalyzer.cs (0e96dd08 #792; 2e916031 E2). ScriptEvaluator throws + ScriptSandboxViolationException at compile, so the VT / Calculation tag / scripted alarm never evaluates (and the + /scripts editor shows the diagnostic). Text search over the raw source — case-sensitive, may over-report + (e.g. a word in a comment); patterns flagged 'review' are known to collide with legitimate APIs. + --------------------------------------------------------------------------------------------- */ +DECLARE @Pat TABLE (pattern nvarchar(100) COLLATE Latin1_General_BIN2, denied nvarchar(200), source nvarchar(40), certainty nvarchar(10)); +INSERT @Pat VALUES + (N'%AppContext%', N'System.AppContext', N'#792 0e96dd08', N'likely'), + (N'%Console.%', N'System.Console', N'#792 0e96dd08', N'likely'), + (N'%GCSettings%', N'System.Runtime.GCSettings', N'#792 0e96dd08', N'likely'), + (N'%ProfileOptimization%', N'System.Runtime.ProfileOptimization', N'#792 0e96dd08', N'likely'), + (N'%EncodingProvider%', N'System.Text.(CodePages)EncodingProvider', N'#792 0e96dd08', N'likely'), + (N'%TypeDescriptor%', N'System.ComponentModel.TypeDescriptor', N'#792 0e96dd08', N'likely'), + (N'%CryptoConfig%', N'System.Security.Cryptography.CryptoConfig', N'#792 0e96dd08', N'likely'), + (N'%CurrentCulture%', N'CultureInfo.CurrentCulture / DefaultThreadCurrentCulture', N'#792 0e96dd08', N'likely'), + (N'%CurrentUICulture%', N'CultureInfo.CurrentUICulture / DefaultThreadCurrentUICulture', N'#792 0e96dd08', N'likely'), + (N'%ClearCachedData%', N'TimeZoneInfo.ClearCachedData', N'#792 0e96dd08', N'likely'), + (N'%RuntimeHelpers%', N'System.Runtime.CompilerServices.RuntimeHelpers', N'E2 2e916031', N'likely'), + (N'%System.Threading%', N'System.Threading namespace (all but CancellationToken/Interlocked/Volatile)', N'E2 2e916031', N'likely'), + (N'%SemaphoreSlim%', N'System.Threading.SemaphoreSlim', N'E2 2e916031', N'likely'), + (N'%Monitor.%', N'System.Threading.Monitor', N'E2 2e916031', N'review'), + (N'%SpinWait%', N'System.Threading.SpinWait', N'E2 2e916031', N'likely'), + (N'%SpinLock%', N'System.Threading.SpinLock', N'E2 2e916031', N'likely'), + (N'%ResetEvent%', N'System.Threading.Manual/AutoResetEvent(Slim)', N'E2 2e916031', N'likely'), + (N'%Mutex%', N'System.Threading.Mutex', N'E2 2e916031', N'likely'), + (N'%WaitHandle%', N'System.Threading.(Event)WaitHandle', N'E2 2e916031', N'likely'), + (N'%CancellationTokenSource%', N'System.Threading.CancellationTokenSource', N'E2 2e916031', N'likely'), + (N'%ReaderWriterLock%', N'System.Threading.ReaderWriterLock(Slim)', N'E2 2e916031', N'likely'), + (N'%ThreadLocal%', N'System.Threading.ThreadLocal', N'E2 2e916031', N'likely'), + (N'%AsyncLocal%', N'System.Threading.AsyncLocal', N'E2 2e916031', N'likely'), + (N'%CountdownEvent%', N'System.Threading.CountdownEvent', N'E2 2e916031', N'likely'), + (N'%Barrier%', N'System.Threading.Barrier', N'E2 2e916031', N'review'), + (N'%Thread.%', N'System.Threading.Thread (already denied pre-E2)', N'E2 2e916031', N'review'), + (N'%Type.GetType(%', N'System.Type.GetType (reflection entry point)', N'E2 2e916031', N'likely'), + (N'%.InvokeMember(%', N'System.Type.InvokeMember', N'E2 2e916031', N'likely'), + (N'%.GetMethod%(%', N'System.Type.GetMethod(s)', N'E2 2e916031', N'review'), + (N'%.GetPropert%(%', N'System.Type.GetProperty/GetProperties (JsonElement.GetProperty is FINE)', N'E2 2e916031', N'review'), + (N'%.GetField%(%', N'System.Type.GetField(s)', N'E2 2e916031', N'review'), + (N'%.GetMember%(%', N'System.Type.GetMember(s)', N'E2 2e916031', N'review'), + (N'%.GetConstructor%(%', N'System.Type.GetConstructor(s)', N'E2 2e916031', N'review'), + (N'%.GetEvent%(%', N'System.Type.GetEvent(s)', N'E2 2e916031', N'review'), + (N'%.GetDefaultMembers(%', N'System.Type.GetDefaultMembers', N'E2 2e916031', N'likely'); + +DECLARE @c5 TABLE (ScriptId nvarchar(64), ScriptName nvarchar(128), used_by nvarchar(max), denied_api nvarchar(200), certainty nvarchar(10), source nvarchar(40)); +INSERT @c5 +SELECT s.ScriptId, s.Name, + STUFF((SELECT N', ' + u.UsedByKind + N':' + u.UsedById + CASE WHEN u.UsedByEnabled = 0 THEN N'(disabled)' ELSE N'' END + FROM @ScriptUse u WHERE u.ScriptId = s.ScriptId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N''), + p.denied, p.certainty, p.source +FROM dbo.Script s +INNER JOIN @Pat p ON s.SourceCode COLLATE Latin1_General_BIN2 LIKE p.pattern +WHERE s.ScriptId IS NOT NULL; +SELECT N'5' AS check_id, N'#792/E2 newly forbidden script APIs' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'NO (fails at compile/run)' AS blocking, N'ForbiddenTypeAnalyzer; commits 0e96dd08, 2e916031' AS source FROM @c5; +SELECT * FROM @c5 ORDER BY certainty, ScriptId, denied_api; + +/* --------------------------------------------------------------------------------------------- + Credential-field walk shared by checks 6-7 (#815/#835/#849/#836). + Policy (Core.Abstractions/DriverCredentialPolicy.cs + CredentialReferences.cs), fields matched at ANY depth, + key compared case-insensitively, in DriverConfig AND every DeviceConfig of the driver: + GalaxyMxGateway: ApiKeySecretRef (literal allowed), FallbackPasswordSecretRef (literal only as dev:...) + secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... | env:GALAXY_MXGW_... + OpcUaClient: Password, UserCertificatePassword (literal allowed); path field UserCertificatePath (#836) + secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_... + Heidenhain: InspectPassword, DncPassword, DataPassword (literal refused) — type did not exist at b8b4c18f. + ${secret:name} is the same as secret:name. file: is always refused. A prefix in another casing is refused. + --------------------------------------------------------------------------------------------- */ +DECLARE @Field TABLE (DriverType nvarchar(32), FieldName nvarchar(64), LiteralPolicy nvarchar(16)); +INSERT @Field VALUES + (N'GalaxyMxGateway', N'ApiKeySecretRef', N'Allowed'), (N'GalaxyMxGateway', N'FallbackPasswordSecretRef', N'DevPrefixOnly'), + (N'OpcUaClient', N'Password', N'Allowed'), (N'OpcUaClient', N'UserCertificatePassword', N'Allowed'), + (N'Heidenhain', N'InspectPassword', N'Refused'), (N'Heidenhain', N'DncPassword', N'Refused'), (N'Heidenhain', N'DataPassword', N'Refused'); +DECLARE @Ns TABLE (DriverType nvarchar(32), Kind nvarchar(10), Prefix nvarchar(64) COLLATE Latin1_General_BIN2); +INSERT @Ns VALUES + (N'GalaxyMxGateway', N'secret', N'galaxy/'), (N'GalaxyMxGateway', N'secret', N'otopcua/galaxy/'), + (N'GalaxyMxGateway', N'env', N'OTOPCUA_GALAXY_'), (N'GalaxyMxGateway', N'env', N'GALAXY_MXGW_'), + (N'OpcUaClient', N'secret', N'opcuaclient/'), (N'OpcUaClient', N'env', N'OTOPCUA_OPCUACLIENT_'), + (N'Heidenhain', N'secret', N'heidenhain/'), (N'Heidenhain', N'env', N'OTOPCUA_HEIDENHAIN_'); + +-- Credential-bearing config blobs: DriverConfig + each DeviceConfig. +DECLARE @Blob TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128), Config nvarchar(max)); +INSERT @Blob +SELECT d.DriverInstanceId, d.Name, ft.DriverType, N'DriverConfig', d.DriverInstanceId, d.Name, d.DriverConfig +FROM dbo.DriverInstance d INNER JOIN (SELECT DISTINCT DriverType FROM @Field) ft ON ft.DriverType = d.DriverType -- table is case-insensitive like the C# policy +UNION ALL +SELECT d.DriverInstanceId, d.Name, ft.DriverType, N'DeviceConfig', v.DeviceId, v.Name, v.DeviceConfig +FROM dbo.DriverInstance d INNER JOIN (SELECT DISTINCT DriverType FROM @Field) ft ON ft.DriverType = d.DriverType +INNER JOIN dbo.Device v ON v.DriverInstanceId = d.DriverInstanceId; + +-- Recursive JSON walk: every property at every depth, with its parent object (for the #836 AuthType switch). +DECLARE @Prop TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128), + FieldPath nvarchar(4000), PropKey nvarchar(4000), PropValue nvarchar(max) COLLATE Latin1_General_BIN2, PropType tinyint, ParentJson nvarchar(max)); +WITH j AS ( + SELECT b.DriverInstanceId, b.DriverName, b.DriverType, b.BlobKind, b.OwnerId, b.OwnerName, + CAST(o.[key] AS nvarchar(4000)) AS FieldPath, CAST(o.[key] AS nvarchar(4000)) AS PropKey, o.[value] AS PropValue, CAST(o.[type] AS tinyint) AS PropType, + CAST(b.Config AS nvarchar(max)) AS ParentJson, 0 AS depth, CAST(0 AS bit) AS InArray + FROM @Blob b CROSS APPLY OPENJSON(CASE WHEN ISJSON(b.Config) = 1 THEN b.Config END) o + UNION ALL + SELECT j.DriverInstanceId, j.DriverName, j.DriverType, j.BlobKind, j.OwnerId, j.OwnerName, + CAST(CASE WHEN j.PropType = 4 THEN j.FieldPath + N'[' + c.[key] + N']' ELSE j.FieldPath + N'.' + c.[key] END AS nvarchar(4000)), + CAST(c.[key] AS nvarchar(4000)), c.[value], CAST(c.[type] AS tinyint), CAST(j.PropValue AS nvarchar(max)), j.depth + 1, + CAST(CASE WHEN j.PropType = 4 THEN 1 ELSE 0 END AS bit) + FROM j CROSS APPLY OPENJSON(j.PropValue) c + WHERE j.PropType IN (4, 5) AND j.depth < 32 +) +INSERT @Prop SELECT DriverInstanceId, DriverName, DriverType, BlobKind, OwnerId, OwnerName, FieldPath, PropKey, PropValue, PropType, ParentJson +FROM j WHERE InArray = 0 OPTION (MAXRECURSION 40); + +-- Classify every credential field value (CredentialReferences.Parse + IsAcceptable). +DECLARE @Cred TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerId nvarchar(64), OwnerName nvarchar(128), + FieldPath nvarchar(4000), LiteralPolicy nvarchar(16), Kind nvarchar(80), RefName nvarchar(4000) COLLATE Latin1_General_BIN2, Acceptable bit, Shown nvarchar(4000)); +INSERT @Cred +SELECT p.DriverInstanceId, p.DriverName, p.DriverType, p.BlobKind, p.OwnerId, p.OwnerName, p.FieldPath, f.LiteralPolicy, k.Kind, k.RefName, + CASE WHEN k.Kind IN (N'blank', N'secret', N'env') THEN 1 + WHEN k.Kind = N'literal' AND f.LiteralPolicy = N'Allowed' THEN 1 + WHEN k.Kind = N'literal' AND f.LiteralPolicy = N'DevPrefixOnly' AND LOWER(LEFT(p.PropValue, 4)) = N'dev:' THEN 1 + ELSE 0 END, + CASE WHEN k.Kind IN (N'literal') THEN N'' WHEN k.Kind = N'non-string' THEN N'' + WHEN LOWER(LEFT(p.PropValue, 4)) = N'dev:' THEN N'dev:' ELSE p.PropValue END +FROM @Prop p +INNER JOIN @Field f ON f.DriverType = p.DriverType AND LOWER(f.FieldName) = LOWER(p.PropKey) +CROSS APPLY ( + SELECT + CASE WHEN p.PropType = 0 OR (p.PropType = 1 AND p.PropValue = N'') THEN N'blank' + WHEN p.PropType <> 1 THEN N'non-string' + WHEN (LOWER(LEFT(p.PropValue, 9)) = N'${secret:' AND LEFT(p.PropValue, 9) <> N'${secret:') + OR (LOWER(LEFT(p.PropValue, 7)) = N'secret:' AND LEFT(p.PropValue, 7) <> N'secret:') + OR (LOWER(LEFT(p.PropValue, 4)) = N'env:' AND LEFT(p.PropValue, 4) <> N'env:') THEN N'out-of-scope (prefix casing)' + WHEN LEFT(p.PropValue, 9) = N'${secret:' AND RIGHT(p.PropValue, 1) <> N'}' THEN N'out-of-scope (malformed ${secret:})' + WHEN LEFT(p.PropValue, 9) = N'${secret:' OR LEFT(p.PropValue, 7) = N'secret:' THEN N'secret?' + WHEN LEFT(p.PropValue, 4) = N'env:' THEN N'env?' + WHEN LOWER(LEFT(p.PropValue, 5)) = N'file:' THEN N'out-of-scope (file:)' + ELSE N'literal' END AS RawKind, + CASE WHEN LEFT(p.PropValue, 9) = N'${secret:' AND RIGHT(p.PropValue, 1) = N'}' THEN SUBSTRING(p.PropValue, 10, LEN(p.PropValue) - 10) + WHEN LEFT(p.PropValue, 7) = N'secret:' THEN SUBSTRING(p.PropValue, 8, 4000) + WHEN LEFT(p.PropValue, 4) = N'env:' THEN SUBSTRING(p.PropValue, 5, 4000) END AS Name +) rk +CROSS APPLY ( + SELECT + CASE WHEN rk.RawKind = N'secret?' THEN + CASE WHEN EXISTS (SELECT 1 FROM @Ns n WHERE n.DriverType = p.DriverType AND n.Kind = N'secret' + AND LEFT(rk.Name, LEN(n.Prefix)) = n.Prefix + AND LEN(rk.Name) - LEN(n.Prefix) BETWEEN 1 AND 200 + AND SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) NOT LIKE N'%[^-A-Za-z0-9._/]%' + AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%//%' + AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%/./%' + AND N'/' + SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) + N'/' NOT LIKE N'%/../%') + THEN N'secret' ELSE N'out-of-scope (secret: outside namespace)' END + WHEN rk.RawKind = N'env?' THEN + CASE WHEN EXISTS (SELECT 1 FROM @Ns n WHERE n.DriverType = p.DriverType AND n.Kind = N'env' + AND LEFT(rk.Name, LEN(n.Prefix)) = n.Prefix + AND LEN(rk.Name) - LEN(n.Prefix) BETWEEN 1 AND 100 + AND SUBSTRING(rk.Name, LEN(n.Prefix) + 1, 4000) NOT LIKE N'%[^A-Z0-9_]%') + THEN N'env' ELSE N'out-of-scope (env: outside prefix)' END + ELSE rk.RawKind END AS Kind, + rk.Name AS RefName +) k; + +/* --------------------------------------------------------------------------------------------- + Check 6 — #815 CredentialReferenceOutOfScope (+ #836 UserCertificatePath) (BLOCKING) + master: Configuration/Validation/CredentialReferenceDeployChecks.cs (a224fd63 #815; 63331b04 #836; 3dbfe6ec #850). + A RUNNING driver keeps resolving an old reference warn-only (LegacyCredentialReferenceTracker), but the NEXT deploy + is refused until the field is migrated. Same rule the AdminUI /raw banner and CredentialReferencePreflightService + report (#834). Also lists config blobs that are not valid JSON (the gate reports those too). + UserCertificatePath: must be blank or a relative path of [A-Za-z0-9._-] segments inside + Drivers:OpcUaClient:UserCertificateDirectory; only checked where AuthType in the SAME object is absent/null/ + 'Certificate'/2 — rows where AuthType sits in the other blob are 'review' (the gate checks the merged config, #850). + --------------------------------------------------------------------------------------------- */ +DECLARE @c6 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), blob nvarchar(30), owner_name nvarchar(128), field_path nvarchar(4000), + finding nvarchar(200), value_shown nvarchar(4000), severity nvarchar(10)); +INSERT @c6 +SELECT c.DriverType, c.DriverName, c.DriverInstanceId, c.BlobKind, c.OwnerName, c.FieldPath, + CASE WHEN c.Kind = N'literal' AND c.LiteralPolicy = N'DevPrefixOnly' THEN N'literal refused: must be secret:/env: in namespace or explicit dev:VALUE' + WHEN c.Kind = N'literal' THEN N'literal refused for this field' + ELSE c.Kind END, + c.Shown, N'blocking' +FROM @Cred c WHERE c.Acceptable = 0; +INSERT @c6 +SELECT b.DriverType, b.DriverName, b.DriverInstanceId, b.BlobKind, b.OwnerName, N'(whole blob)', N'config is not valid JSON (credential fields cannot be checked) — verify; comments/trailing commas may be a false positive', NULL, N'blocking' +FROM @Blob b WHERE ISJSON(b.Config) = 0 AND LTRIM(RTRIM(ISNULL(b.Config, N''))) <> N''; +INSERT @c6 +SELECT p.DriverType, p.DriverName, p.DriverInstanceId, p.BlobKind, p.OwnerName, p.FieldPath, + CASE WHEN p.PropType <> 1 THEN N'#836 UserCertificatePath is not a string' + ELSE N'#836 UserCertificatePath must be a relative path inside Drivers:OpcUaClient:UserCertificateDirectory' END, + CASE WHEN p.PropType = 1 THEN p.PropValue END, + CASE WHEN a.AuthType IS NULL OR a.AuthTypeType = 0 OR (a.AuthTypeType = 1 AND LOWER(a.AuthType) = N'certificate') + OR (a.AuthTypeType = 2 AND (TRY_CAST(a.AuthType AS int) IS NULL OR TRY_CAST(a.AuthType AS int) = 2)) OR a.AuthTypeType NOT IN (0, 1, 2) + THEN N'blocking' ELSE N'review' END +FROM @Prop p +OUTER APPLY (SELECT TOP 1 o.[value] AS AuthType, o.[type] AS AuthTypeType FROM OPENJSON(p.ParentJson) o WHERE LOWER(o.[key]) = N'authtype') a +WHERE p.DriverType = N'OpcUaClient' AND LOWER(p.PropKey) = N'usercertificatepath' + AND p.PropType <> 0 AND NOT (p.PropType = 1 AND p.PropValue = N'') + AND (p.PropType <> 1 + OR LEN(p.PropValue) > 260 + OR LEFT(p.PropValue, 1) IN (N'/', N'\') + OR p.PropValue LIKE N'_:%' + OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%//%' + OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%/./%' + OR N'/' + REPLACE(p.PropValue, N'\', N'/') + N'/' LIKE N'%/../%' + OR REPLACE(p.PropValue, N'\', N'/') LIKE N'%[^-A-Za-z0-9._/]%'); +SELECT N'6' AS check_id, N'#815/#836 CredentialReferenceOutOfScope' AS title, + CASE WHEN SUM(CASE WHEN severity = N'blocking' THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'CredentialReferenceDeployChecks / DriverCredentialPolicy; commits a224fd63, 63331b04, 3dbfe6ec' AS source FROM @c6; +SELECT * FROM @c6 ORDER BY severity, DriverType, DriverName, field_path; + +/* --------------------------------------------------------------------------------------------- + Check 7 — #835/#849 CredentialReferenceOtherInstance (BLOCKING) + master: DriverCredentialPolicy.DescribeOtherInstanceProblems via CredentialReferenceDeployChecks (63331b04 #835, + 4a6229e4 #849, d68dff09). An in-scope reference whose instance part names ANOTHER driver of the same type: + secret://... X equals another driver's id or name (case-insensitive); + env:... X equals another driver's id/name in env form (ASCII letters upper-cased, digits kept, every + other char '_'), alone or followed by '_'. + An id-shaped part (secret DRV-<12 hex>, env DRV_<12 HEX>) matches by id only. + --------------------------------------------------------------------------------------------- */ +DECLARE @Drv TABLE (DriverInstanceId nvarchar(64), Name nvarchar(128), DriverType nvarchar(32), IdNorm nvarchar(256) COLLATE Latin1_General_BIN2, NameNorm nvarchar(256) COLLATE Latin1_General_BIN2); +INSERT @Drv +SELECT d.DriverInstanceId, d.Name, d.DriverType, + CASE WHEN d.DriverInstanceId COLLATE Latin1_General_BIN2 LIKE N'%[A-Za-z0-9]%' THEN + (SELECT CASE WHEN SUBSTRING(d.DriverInstanceId, n.n, 1) COLLATE Latin1_General_BIN2 LIKE N'[A-Za-z0-9]' THEN UPPER(SUBSTRING(d.DriverInstanceId, n.n, 1)) ELSE N'_' END + FROM @Num n WHERE n.n <= DATALENGTH(d.DriverInstanceId) / 2 ORDER BY n.n FOR XML PATH('')) ELSE N'' END, + CASE WHEN d.Name COLLATE Latin1_General_BIN2 LIKE N'%[A-Za-z0-9]%' THEN + (SELECT CASE WHEN SUBSTRING(d.Name, n.n, 1) COLLATE Latin1_General_BIN2 LIKE N'[A-Za-z0-9]' THEN UPPER(SUBSTRING(d.Name, n.n, 1)) ELSE N'_' END + FROM @Num n WHERE n.n <= DATALENGTH(d.Name) / 2 ORDER BY n.n FOR XML PATH('')) ELSE N'' END +FROM dbo.DriverInstance d WHERE d.DriverType IN (SELECT DriverType FROM @Field); + +DECLARE @InstRef TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), DriverType nvarchar(32), BlobKind nvarchar(30), OwnerName nvarchar(128), FieldPath nvarchar(4000), + Kind nvarchar(10), Part nvarchar(4000) COLLATE Latin1_General_BIN2, IdShaped bit, Shown nvarchar(4000)); +INSERT @InstRef +SELECT c.DriverInstanceId, c.DriverName, c.DriverType, c.BlobKind, c.OwnerName, c.FieldPath, c.Kind, x.Part, + CASE WHEN c.Kind = N'secret' THEN CASE WHEN LEN(x.Part) = 16 AND UPPER(LEFT(x.Part, 4)) = N'DRV-' AND SUBSTRING(x.Part, 5, 12) NOT LIKE N'%[^0-9A-Fa-f]%' THEN 1 ELSE 0 END + ELSE CASE WHEN LEFT(x.Part, 4) = N'DRV_' AND LEN(x.Part) >= 16 AND SUBSTRING(x.Part, 5, 12) NOT LIKE N'%[^0-9A-F]%' + AND (LEN(x.Part) = 16 OR SUBSTRING(x.Part, 17, 1) = N'_') THEN 1 ELSE 0 END END, + c.Shown +FROM @Cred c +CROSS APPLY (SELECT TOP 1 n.Prefix FROM @Ns n WHERE n.DriverType = c.DriverType AND n.Kind = c.Kind AND LEFT(c.RefName, LEN(n.Prefix)) = n.Prefix ORDER BY LEN(n.Prefix) DESC) ns +CROSS APPLY (SELECT SUBSTRING(c.RefName, LEN(ns.Prefix) + 1, 4000) AS Rest) r +CROSS APPLY (SELECT CASE WHEN c.Kind = N'secret' AND CHARINDEX(N'/', r.Rest) > 0 THEN LEFT(r.Rest, CHARINDEX(N'/', r.Rest) - 1) ELSE r.Rest END AS Part) x +WHERE c.Kind IN (N'secret', N'env'); + +DECLARE @c7 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), blob nvarchar(30), owner_name nvarchar(128), field_path nvarchar(4000), + reference nvarchar(4000), names_other_driver nvarchar(128), other_driver_id nvarchar(64), matched_on nvarchar(10)); +INSERT @c7 +SELECT i.DriverType, i.DriverName, i.DriverInstanceId, i.BlobKind, i.OwnerName, i.FieldPath, i.Shown, o.Name, o.DriverInstanceId, + CASE WHEN (i.Kind = N'secret' AND LOWER(i.Part) = LOWER(o.DriverInstanceId) COLLATE Latin1_General_BIN2) + OR (i.Kind = N'env' AND o.IdNorm <> N'' AND (i.Part = o.IdNorm OR LEFT(i.Part, LEN(o.IdNorm) + 1) = o.IdNorm + N'_')) THEN N'id' ELSE N'name' END +FROM @InstRef i +INNER JOIN @Drv o ON LOWER(o.DriverType) = LOWER(i.DriverType) AND o.DriverInstanceId <> i.DriverInstanceId +WHERE (i.Kind = N'secret' AND (LOWER(i.Part) = LOWER(o.DriverInstanceId) COLLATE Latin1_General_BIN2 OR (i.IdShaped = 0 AND LOWER(i.Part) = LOWER(o.Name) COLLATE Latin1_General_BIN2))) + OR (i.Kind = N'env' AND ( + (o.IdNorm <> N'' AND (i.Part = o.IdNorm OR LEFT(i.Part, LEN(o.IdNorm) + 1) = o.IdNorm + N'_')) + OR (i.IdShaped = 0 AND o.NameNorm <> N'' AND (i.Part = o.NameNorm OR LEFT(i.Part, LEN(o.NameNorm) + 1) = o.NameNorm + N'_')))); +SELECT N'7' AS check_id, N'#835/#849 CredentialReferenceOtherInstance' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DriverCredentialPolicy.DescribeOtherInstanceProblems; commits 63331b04, 4a6229e4, d68dff09' AS source FROM @c7; +SELECT * FROM @c7 ORDER BY DriverType, DriverName, field_path; + +/* --------------------------------------------------------------------------------------------- + Check 8 — #848/#849 reserved / claimed driver names (AUTHORING, not a deploy refusal) + master: DriverCredentialPolicy.IsReservedInstanceName + DriverConfigSaveGate.DescribeNameChangeProblemsAsync (d08e59ee, + d68dff09). A credential-bearing driver named 'drv' / 'DRV-<12 hex>...' (env form DRV or DRV_<12 HEX>) cannot be + created or renamed to that name; listed so a later rename/save surprise is expected. + --------------------------------------------------------------------------------------------- */ +DECLARE @c8 TABLE (DriverType nvarchar(32), DriverName nvarchar(128), DriverInstanceId nvarchar(64), env_form nvarchar(256), finding nvarchar(200)); +INSERT @c8 +SELECT d.DriverType, d.Name, d.DriverInstanceId, d.NameNorm, N'name is reserved (collides with the DRV- credential convention)' +FROM @Drv d +WHERE d.NameNorm = N'DRV' + OR (LEFT(d.NameNorm, 4) = N'DRV_' AND LEN(d.NameNorm) >= 16 AND SUBSTRING(d.NameNorm, 5, 12) NOT LIKE N'%[^0-9A-F]%' + AND (LEN(d.NameNorm) = 16 OR SUBSTRING(d.NameNorm, 17, 1) = N'_')); +SELECT N'8' AS check_id, N'#848/#849 reserved driver names (authoring only)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'NO (save/rename refused)' AS blocking, N'DriverCredentialPolicy.IsReservedInstanceName; commits d08e59ee, d68dff09' AS source FROM @c8; +SELECT * FROM @c8; + +/* --------------------------------------------------------------------------------------------- + Check 9 — MTConnectMultipleDevices (BLOCKING) + master: DraftValidator.ValidateMTConnectSingleDevice (dcc827ca, #628). More than one /raw Device under one + MTConnect driver instance. + --------------------------------------------------------------------------------------------- */ +DECLARE @c9 TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128), device_count int, devices nvarchar(max)); +INSERT @c9 +SELECT d.DriverInstanceId, d.Name, COUNT(*), + STUFF((SELECT N', ' + v2.Name FROM dbo.Device v2 WHERE v2.DriverInstanceId = d.DriverInstanceId FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N'') +FROM dbo.DriverInstance d INNER JOIN dbo.Device v ON v.DriverInstanceId = d.DriverInstanceId +WHERE d.DriverType COLLATE Latin1_General_BIN2 = N'MTConnect' +GROUP BY d.DriverInstanceId, d.Name HAVING COUNT(*) > 1; +SELECT N'9' AS check_id, N'MTConnectMultipleDevices' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateMTConnectSingleDevice; commit dcc827ca (#628)' AS source FROM @c9; +SELECT * FROM @c9; + +/* --------------------------------------------------------------------------------------------- + Check 10 — #781 Sql tag gates + 10a SqlQueryNotDeclared (BLOCKING, approximate): a Query-model tag ("model":"Query") whose "query" is not a key of + the driver's merged "queries" map (DriverConfig, shallow-overridden by the DeviceConfig when the driver has + exactly one Device — DriverDeviceConfigMerger). master: SqlTagDeployChecks.UndeclaredQueryErrors (241318e7). + 10b Sql tags carrying a "write" block (REVIEW): the block is now parsed (f5c30ed0); an invalid block is + SqlWriteBlockInvalid (BLOCKING, and the driver would skip the whole tag), Upsert/Procedure on an Odbc provider + is SqlWriteModeUnsupported (BLOCKING). master: SqlTagDeployChecks.WriteBlockErrors. + 10c Writable mismatch (WARNING only, logged at deploy): Tag.AccessLevel=ReadWrite vs TagConfig "writable": true. + --------------------------------------------------------------------------------------------- */ +DECLARE @c10 TABLE (sub nvarchar(4), TagId nvarchar(64), RawPath nvarchar(4000), DriverName nvarchar(128), finding nvarchar(300), severity nvarchar(10)); +WITH sqlTag AS ( + SELECT r.*, (SELECT COUNT(*) FROM dbo.Device v WHERE v.DriverInstanceId = r.DriverInstanceId) AS DeviceCount + FROM @RawTag r WHERE r.DriverType = N'Sql' AND ISJSON(r.TagConfig) = 1 +) +INSERT @c10 +SELECT N'10a', t.TagId, t.RawPath, t.DriverName, N'query ''' + q.QueryName + N''' not declared under the driver''s queries', N'blocking' +FROM sqlTag t +CROSS APPLY (SELECT JSON_VALUE(t.TagConfig, '$.query') AS QueryName, JSON_VALUE(t.TagConfig, '$.model') AS Model) q +OUTER APPLY (SELECT TOP 1 v.DeviceConfig FROM dbo.Device v WHERE v.DriverInstanceId = t.DriverInstanceId AND t.DeviceCount = 1 + AND ISJSON(v.DeviceConfig) = 1 AND EXISTS (SELECT 1 FROM OPENJSON(v.DeviceConfig) o WHERE o.[key] COLLATE Latin1_General_BIN2 = N'queries')) dev +CROSS APPLY (SELECT ISNULL(dev.DeviceConfig, (SELECT di.DriverConfig FROM dbo.DriverInstance di WHERE di.DriverInstanceId = t.DriverInstanceId)) AS Cfg) m +WHERE LOWER(q.Model) = N'query' AND q.QueryName IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM OPENJSON(CASE WHEN ISJSON(m.Cfg) = 1 THEN m.Cfg END) top1 + CROSS APPLY OPENJSON(CASE WHEN top1.[type] = 5 THEN top1.[value] END) qk + WHERE LOWER(top1.[key]) = N'queries' AND qk.[key] COLLATE Latin1_General_BIN2 = q.QueryName COLLATE Latin1_General_BIN2); +INSERT @c10 +SELECT N'10b', r.TagId, r.RawPath, r.DriverName, N'TagConfig has a "write" block — verify it parses (mode/table/keys) and the provider serves the mode', N'review' +FROM @RawTag r WHERE r.DriverType = N'Sql' AND ISJSON(r.TagConfig) = 1 + AND EXISTS (SELECT 1 FROM OPENJSON(r.TagConfig) o WHERE o.[key] COLLATE Latin1_General_BIN2 = N'write'); +INSERT @c10 +SELECT N'10c', r.TagId, r.RawPath, r.DriverName, + CASE WHEN r.AccessLevel = N'ReadWrite' THEN N'AccessLevel ReadWrite but TagConfig lacks "writable": true — writes refused' + ELSE N'TagConfig "writable": true but AccessLevel is not ReadWrite — node read-only' END, N'warning' +FROM @RawTag r +CROSS APPLY (SELECT CASE WHEN EXISTS (SELECT 1 FROM OPENJSON(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END) o + WHERE o.[key] COLLATE Latin1_General_BIN2 = N'writable' AND o.[type] = 3 AND o.[value] = N'true') THEN 1 ELSE 0 END AS BlobWritable) w +WHERE r.DriverType = N'Sql' AND (CASE WHEN r.AccessLevel = N'ReadWrite' THEN 1 ELSE 0 END) <> w.BlobWritable; +SELECT N'10' AS check_id, N'#781 Sql tag gates' AS title, + CASE WHEN SUM(CASE WHEN severity IN (N'blocking', N'review') THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status, + COUNT(*) AS row_count, N'10a/10b YES, 10c NO' AS blocking, N'SqlTagDeployChecks; commits 241318e7, f5c30ed0' AS source FROM @c10; +SELECT * FROM @c10 ORDER BY sub, RawPath; + +/* --------------------------------------------------------------------------------------------- + Check 11 — #803 Heidenhain gates (BLOCKING) — sanity only: the Heidenhain driver type did not exist at b8b4c18f, + so any row here was written by something other than the b8b4c18f AdminUI. + master: HeidenhainDeployChecks (2b45694f). + --------------------------------------------------------------------------------------------- */ +DECLARE @c11 TABLE (DriverInstanceId nvarchar(64), DriverName nvarchar(128)); +INSERT @c11 SELECT DriverInstanceId, Name FROM dbo.DriverInstance WHERE LOWER(DriverType) = N'heidenhain'; +SELECT N'11' AS check_id, N'#803 Heidenhain drivers present (expected none)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'HeidenhainDeployChecks; commit 2b45694f' AS source FROM @c11; +SELECT * FROM @c11; + +/* --------------------------------------------------------------------------------------------- + Check 12 — UNS effective-name collisions (pre-existing rule, unchanged; re-checked because the whole DB is the + draft). master: DraftValidator.ValidateUnsEffectiveLeafUniqueness. References (DisplayNameOverride else raw tag + Name), VirtualTags and ScriptedAlarms share {EquipmentId}/{EffectiveName}; compared ordinally. + --------------------------------------------------------------------------------------------- */ +DECLARE @Leaf TABLE (EquipmentId nvarchar(64) COLLATE Latin1_General_BIN2, EffName nvarchar(128) COLLATE Latin1_General_BIN2, Src nvarchar(200)); +INSERT @Leaf SELECT u.EquipmentId, ISNULL(u.DisplayNameOverride, t.Name), N'UnsTagReference ' + u.UnsTagReferenceId +FROM dbo.UnsTagReference u LEFT JOIN dbo.Tag t ON t.TagId = u.TagId WHERE ISNULL(u.DisplayNameOverride, t.Name) <> N''; +INSERT @Leaf SELECT EquipmentId, Name, N'VirtualTag ' + VirtualTagId FROM dbo.VirtualTag WHERE Name <> N''; +INSERT @Leaf SELECT EquipmentId, Name, N'ScriptedAlarm ' + ScriptedAlarmId FROM dbo.ScriptedAlarm WHERE Name <> N''; +DECLARE @c12 TABLE (EquipmentId nvarchar(64), EffectiveName nvarchar(128), sources nvarchar(max)); +INSERT @c12 +SELECT l.EquipmentId, l.EffName, + STUFF((SELECT N', ' + l2.Src FROM @Leaf l2 WHERE l2.EquipmentId = l.EquipmentId AND l2.EffName = l.EffName FOR XML PATH(''), TYPE).value('.', 'nvarchar(max)'), 1, 2, N'') +FROM @Leaf l GROUP BY l.EquipmentId, l.EffName HAVING COUNT(*) > 1; +SELECT N'12' AS check_id, N'UnsEffectiveName collisions (pre-existing rule)' AS title, CASE WHEN COUNT(*) = 0 THEN N'ok' ELSE N'attention' END AS status, + COUNT(*) AS row_count, N'YES' AS blocking, N'DraftValidator.ValidateUnsEffectiveLeafUniqueness (unchanged since b8b4c18f)' AS source FROM @c12; +SELECT * FROM @c12 ORDER BY EquipmentId, EffectiveName; + +/* --------------------------------------------------------------------------------------------- + Check 13 — FOCAS tags whose authored address or value changes under master (the box's one driver) + Not deploy refusals: the TagConfig inspector reports 'String' mismatches at deploy as WARNINGS + (Deployment:TagConfigValidationMode = Warn, the default) and the driver skips that tag (BadNodeIdUnknown). + rename FIXED: ...MaxRpm -> MotorSpeedPct (1dba46bd) + rename FIXED:Axes/AA_1, AA_2 -> AA6, AA7 on the Makino (9460da51 — old names were a decode artefact) + refused dataType String on a PMC / PARAM: / MACRO: address (cc140593, R07:U8) + value FIXED:Axes/... positions scaled by the block envelope (69e0a622: 2801574 -> 280.1574) + value servo/spindle Load: real percent, spindle Load Float64 (1dba46bd) + value MACRO: decoded at the right offset (665ecd55: was off by 10^n); vacant -> BadNoData, + fractional on an integer tag -> BadTypeMismatch + value FIXED:Program/...: an unreported field -> BadNoData instead of Good 0 (26b512ad) + --------------------------------------------------------------------------------------------- */ +DECLARE @c13 TABLE (TagId nvarchar(64), RawPath nvarchar(4000), address nvarchar(400), tag_config_dataType nvarchar(40), entity_DataType nvarchar(32), finding nvarchar(300), severity nvarchar(10)); +INSERT @c13 +SELECT r.TagId, r.RawPath, a.Addr, a.Dt, r.DataType, f.Finding, f.Severity +FROM @RawTag r +CROSS APPLY (SELECT LTRIM(RTRIM(JSON_VALUE(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END, '$.address'))) AS Addr, + JSON_VALUE(CASE WHEN ISJSON(r.TagConfig) = 1 THEN r.TagConfig END, '$.dataType') AS Dt) a +CROSS APPLY ( + SELECT N'rename: MaxRpm is now MotorSpeedPct — re-point the tag (and ScadaBridge)' AS Finding, N'attention' AS Severity WHERE a.Addr LIKE N'%MaxRpm%' + UNION ALL SELECT N'rename: AA_1/AA_2 are now the real axis names (AA6/AA7 on Z-34184) — re-point', N'attention' WHERE a.Addr LIKE N'%AA[_]1%' OR a.Addr LIKE N'%AA[_]2%' + UNION ALL SELECT N'refused: String on a PMC/PARAM/MACRO address — driver skips the tag', N'attention' + WHERE LOWER(a.Dt) = N'string' AND LOWER(LEFT(a.Addr, 6)) <> N'fixed:' AND LOWER(LEFT(a.Addr, 6)) <> N'alarm:' + UNION ALL SELECT N'value change: position now scaled by its envelope', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:axes/%' AND LOWER(a.Addr) LIKE N'%position%' + UNION ALL SELECT N'value change: load now a real percent (spindle Load Float64)', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:%load%' + UNION ALL SELECT N'value change: macro decoded correctly (magnitude changes); vacant = BadNoData', N'info' WHERE LOWER(a.Addr) LIKE N'macro:%' + UNION ALL SELECT N'value change: unreported program field reads BadNoData, not Good 0', N'info' WHERE LOWER(a.Addr) LIKE N'fixed:program%' +) f +WHERE r.DriverType = N'FOCAS'; +SELECT N'13' AS check_id, N'FOCAS address renames / refusals / value changes' AS title, + CASE WHEN SUM(CASE WHEN severity = N'attention' THEN 1 ELSE 0 END) > 0 THEN N'attention' WHEN COUNT(*) > 0 THEN N'info' ELSE N'ok' END AS status, + COUNT(*) AS row_count, N'NO (warn at deploy; client-visible)' AS blocking, N'commits 1dba46bd, 9460da51, cc140593, 69e0a622, 665ecd55, 26b512ad' AS source FROM @c13; +SELECT * FROM @c13 ORDER BY severity, RawPath; + +/* --------------------------------------------------------------------------------------------- + Check 14 — inventory (INFO): what the box actually runs, so the owner can discount driver-specific rows. + --------------------------------------------------------------------------------------------- */ +SELECT N'14' AS check_id, N'Inventory' AS title, N'info' AS status, + (SELECT COUNT(*) FROM dbo.DriverInstance) AS row_count, N'NO' AS blocking, N'-' AS source; +SELECT d.DriverType, COUNT(DISTINCT d.DriverInstanceId) AS drivers, SUM(CASE WHEN d.Enabled = 1 THEN 1 ELSE 0 END) AS enabled_driver_rows, + (SELECT COUNT(*) FROM @RawTag r WHERE r.DriverType = d.DriverType COLLATE Latin1_General_BIN2) AS raw_tags +FROM dbo.DriverInstance d GROUP BY d.DriverType +UNION ALL SELECT N'(VirtualTag)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.VirtualTag +UNION ALL SELECT N'(ScriptedAlarm)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ScriptedAlarm +UNION ALL SELECT N'(Script)', COUNT(*), NULL, NULL FROM dbo.Script +UNION ALL SELECT N'(UnsTagReference)', COUNT(*), NULL, NULL FROM dbo.UnsTagReference +UNION ALL SELECT N'(ApiKey)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ApiKey +UNION ALL SELECT N'(ClusterNode)', COUNT(*), SUM(CASE WHEN Enabled = 1 THEN 1 ELSE 0 END), NULL FROM dbo.ClusterNode;