Merge lane C: control plane, host, security (#733)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:09:08 -04:00
61 changed files with 6603 additions and 453 deletions
+8
View File
@@ -35,6 +35,12 @@
<PackageVersion Include="Grpc.Tools" Version="2.76.0" /> <PackageVersion Include="Grpc.Tools" Version="2.76.0" />
<PackageVersion Include="libplctag" Version="1.5.2" /> <PackageVersion Include="libplctag" Version="1.5.2" />
<PackageVersion Include="MessagePack" Version="2.5.301" /> <PackageVersion Include="MessagePack" Version="2.5.301" />
<!-- First-party bearer validation for /auth/token's JWTs (R03:2.4, Lane C fix round 1). Same 10.0.x servicing
line as the other Microsoft.AspNetCore.* pins. It depends on Microsoft.IdentityModel.Protocols(.OpenIdConnect)
8.0.1; those two are pinned to 8.11.0 below (and referenced directly by the Security project, the JwtBearer
entry point, so the pin applies without transitive pinning) to keep the whole IdentityModel family on one
version with Microsoft.IdentityModel.Tokens / System.IdentityModel.Tokens.Jwt. -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.7" />
@@ -82,6 +88,8 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols" Version="8.11.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.11.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.11.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" /> <PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
+52 -5
View File
@@ -6,11 +6,14 @@
> auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies > auth + role-grant pages live in `OtOpcUa.AdminUI`), and Admin auth policies
> register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth` > register from `OtOpcUa.Host/Program.cs` via `AddOtOpcUaAuth`
> (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather > (`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs`) rather
> than in a separate Admin process. The Admin UI uses a **single Cookie > than in a separate Admin process. The Admin UI session is a **Cookie**
> authentication scheme** — there is no `AddJwtBearer` pipeline. The > scheme; the cookie stores the `ClaimsPrincipal` directly. JWT is **opt-in by
> `Security:Jwt` section configures `JwtTokenService`, which mints a JWT at the > `Security:Jwt:SigningKey`** (validated at host start, ≥32 bytes): with a key,
> `/auth/token` endpoint for **external** consumers (OPC UA clients / automation > `/auth/token` mints an HS256 token for a **cookie session only** (a bearer
> scripts); the cookie itself stores the `ClaimsPrincipal` directly. DataProtection > cannot renew itself), and a first-party `AddJwtBearer` scheme (`Bearer`,
> `ValidAlgorithms = HS256`, parameters from `JwtTokenService.BuildValidationParameters`)
> accepts it on `/api/*` and every `RequireAuthorization` endpoint through the
> `OtOpcUa.CookieOrBearer` default policy scheme. Without a key neither exists. DataProtection
> keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`) > keys persist to the shared Config DB (`PersistKeysToDbContext<OtOpcUaConfigDbContext>`)
> so cookies survive failover between admin-role nodes. > so cookies survive failover between admin-role nodes.
> >
@@ -245,6 +248,25 @@ with two `Security:ApiKeys` knobs:
a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has a completed lookup (a hit or an authoritative miss) is stored, and a cached hit whose `ExpiresUtc` has
since passed is re-read rather than served, so the TTL can never resurrect an expired key. since passed is re-read rather than served, so the TTL can never resurrect an expired key.
#### Pre-authentication guards (`KnownIdSet` / `MissBurst` / `MissRefillPerSecond`)
The negative cache above is keyed by **key id**, so a flood of *distinct*, well-formed `otua_<random>_x`
credentials missed it every time and cost one indexed SQL query per attempt — under the SDK's
`SessionManager`-wide lock, with no credential required (review 03 §3.3). `BoundedApiKeyStore` now puts two
guards in front of the store, bound from the same `Security:ApiKeys` section
(`ApiKeyStoreGuardOptions`, Host-owned):
| Key | Default | Meaning |
|---|---|---|
| `KnownIdSet` | `true` | Keep an in-memory set of every `ApiKeyId` the ConfigDb holds, refreshed every `StoreCacheSeconds` (single-flight; a failed refresh keeps the previous set). An id outside it is refused in memory — `otopcua.opcua.apikey_store.bounded{outcome=unknown_id}` — and the store is never asked. A key minted less than `StoreCacheSeconds` ago is refused until the next refresh (the same window `Enabled` revocation already has). `StoreCacheSeconds = 0` disables the set with the cache. |
| `MissBurst` | `100` | Token bucket on lookups that **reach** the store and **miss**, for when the set is unavailable (not loaded yet, or the store cannot list ids). Exhausted ⇒ unknown ids are refused without a store read (`{outcome=miss_budget}`, a Warning-free deny through the authenticator's catch-all). A hit refunds its token, so legitimate clients are never charged. `0` disables the budget. |
| `MissRefillPerSecond` | `20` | Refill rate of the miss bucket. |
The budget is **process-wide, not per remote endpoint**: the OPC UA SDK's impersonation callback (the
`IOpcUaUserAuthenticator` seam) does not carry the client's address, so per-endpoint accounting needs that
seam widened in the OpcUaServer project (a Wave 3 follow-up). Neither guard applies to a driver-only node:
its `ArtifactApiKeyStore` is two in-memory dictionary probes and is never wrapped.
### LDAP bind flow (`OtOpcUaLdapAuthService`) ### LDAP bind flow (`OtOpcUaLdapAuthService`)
LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)). LDAP is configured under the `Security:Ldap` section (bound to `LdapOptions`, `src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs`, `SectionName = "Security:Ldap"`). The app authenticator is `OtOpcUaLdapAuthService` — a thin wrapper around the shared `ZB.MOM.WW.Auth.Ldap` directory client that adds two app-only concerns the shared library deliberately does not model: the `Enabled` master switch and `DevStubMode`. Since #520 it serves the **Admin UI cookie login (`/auth/login`) only** — `LdapOpcUaUserAuthenticator` is deleted and the OPC UA UserName path authenticates API keys instead (see [API-key identity](#api-key-identity-opc-ua-data-plane)).
@@ -1116,6 +1138,31 @@ See `archreview/plans/R3-01-mesh-grpc-allowanonymous-plan.md` (R3-01) for the fu
record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md` record, and `docs/Configuration.md` §`MeshTransport` / §`ConfigSource`-`ConfigServe` and `docs/Telemetry.md`
for the affected listener ports. for the affected listener ports.
### The known-cluster gate is fail-OPEN until central's first ClusterNode load (review 03 §4.2)
`CentralCommandAuthInterceptor` verifies an inbound deploy ack against the PSK of the cluster named in
`x-otopcua-cluster`. `MeshPskProvider` consults a **known-cluster gate** first — the set of cluster ids
central read from `ClusterNode` on its last contact refresh — and refuses anything outside it in O(1),
touching neither the config map nor the secret store. That gate is **inactive until the first successful
load** (`CentralCommunicationActor` publishes it from `ContactsLoaded`; an empty publication also
deactivates it, deliberately, so "the DB told me nothing" never becomes a fleet-wide authentication
outage). During a central boot with the ConfigDb down, therefore:
- an unauthenticated caller can name *any* `x-otopcua-cluster` value and make central perform one bounded
(3 s) secret-store read plus one Warning line per request — the cost/oracle the gate exists to remove;
- **keys are still required**: nothing is admitted without the matching PSK, so this is a cost exposure,
not an authentication bypass;
- the window is bounded by the bootstrap retry (15 × 2 s) and the `ContactRefreshSeconds` (60 s) periodic
refresh, and closes on the first load that succeeds. The actor logs the condition on every failed load
(*"The known-cluster gate was NOT refreshed either … it is INACTIVE"*).
Since review 03 §3.4 the per-cluster keys are **refreshed asynchronously** from the same handler
(`IMeshPskProvider.RefreshAsync`: concurrent bounded reads, atomic snapshot swap, a failed read keeps the
previous key) instead of being invalidated on every refresh, so the first ack per cluster per minute no
longer pays a synchronous store read on a gRPC thread. Accepted as documented, bounded behaviour; the
alternative — refusing every cluster until SQL answers — would turn a ConfigDb outage into a deploy-ack
outage on top of it.
--- ---
## OTOPCUA0001 Analyzer — Compile-Time Guard ## OTOPCUA0001 Analyzer — Compile-Time Guard
@@ -26,7 +26,11 @@ public sealed class Deployment
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow; public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
/// <summary>Gets or sets the serialized artifact blob containing the configuration.</summary> /// <summary>Gets or sets the serialized artifact blob containing the configuration.</summary>
public byte[] ArtifactBlob { get; init; } = Array.Empty<byte>(); /// <remarks>
/// Settable (not init-only) so the retention sweep can EMPTY it on an old terminal row. An empty blob
/// is "no artifact" to every reader (#485), so the audit row survives with its bytes retired.
/// </remarks>
public byte[] ArtifactBlob { get; set; } = Array.Empty<byte>();
/// <summary>Gets or sets the row version for optimistic concurrency control.</summary> /// <summary>Gets or sets the row version for optimistic concurrency control.</summary>
public byte[] RowVersion { get; set; } = Array.Empty<byte>(); public byte[] RowVersion { get; set; } = Array.Empty<byte>();
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Enables <c>ALLOW_SNAPSHOT_ISOLATION</c> on the config database so the deploy path can read every
/// table inside one <c>SNAPSHOT</c> transaction (<c>DraftSnapshotFactory.FromConfigDbAsync</c>):
/// the validator and the artifact composer then see one consistent state instead of two reads
/// an AdminUI save can land between. No schema change — the model snapshot is untouched.
/// </summary>
/// <remarks>
/// <c>ALTER DATABASE</c> cannot run inside the user transaction the migrator wraps each migration in,
/// hence <c>suppressTransaction: true</c>. Turning the option ON does not need exclusive access (unlike
/// <c>READ_COMMITTED_SNAPSHOT</c>); it waits for in-flight transactions and then takes effect. Down
/// leaves it ON: a database that has run with it is not harmed by keeping it, and switching it OFF
/// would fail any deploy running at that instant.
/// </remarks>
public partial class EnableSnapshotIsolation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON;",
suppressTransaction: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Deliberately a no-op; see the class remarks.
}
}
}
@@ -66,6 +66,23 @@ public sealed class DraftSnapshot
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary> /// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary>
public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = []; public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = [];
/// <summary>
/// Cluster rows. Not read by the row-level rules; carried so the deploy path validates the cluster
/// topology (<see cref="DraftValidator.ValidateClusterTopology"/>) and composes the artifact from
/// the SAME read as every other table — a second read between validate and compose is exactly how
/// an edit ships unvalidated.
/// </summary>
public IReadOnlyList<ServerCluster> ServerClusters { get; init; } = [];
/// <summary>Cluster-node rows; see <see cref="ServerClusters"/> for why they ride the snapshot.</summary>
public IReadOnlyList<ClusterNode> ClusterNodes { get; init; } = [];
/// <summary>
/// Data-plane API keys (peppered HMAC only, never a secret). Not validated; carried so the artifact
/// composer ships the keys a driver-only node authenticates against from the one consistent read.
/// </summary>
public IReadOnlyList<ApiKey> ApiKeys { get; init; } = [];
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary> /// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = []; public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
@@ -1,3 +1,4 @@
using System.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
@@ -21,6 +22,16 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// / <see cref="DraftSnapshot.Site"/> are left null so the path-length rule uses its /// / <see cref="DraftSnapshot.Site"/> are left null so the path-length rule uses its
/// conservative upper bound. /// conservative upper bound.
/// </para> /// </para>
/// <para>
/// <b>One consistent read.</b> On a relational provider the tables are read inside a
/// <see cref="IsolationLevel.Snapshot"/> transaction, so a <c>/uns</c> or <c>/raw</c> save that
/// lands mid-read cannot leave half the tables on the new state and half on the old. The deploy path
/// validates AND composes its artifact from this one snapshot, which is what makes "the artifact the
/// validator never saw" unrepresentable. SQL Server requires <c>ALLOW_SNAPSHOT_ISOLATION ON</c>
/// (migration <c>EnableSnapshotIsolation</c>); without it the first query fails with error 3952 and
/// the deploy is rejected rather than silently read unisolated. A non-relational provider (the
/// InMemory test provider) and a context already inside a transaction read without one.
/// </para>
/// </remarks> /// </remarks>
public static class DraftSnapshotFactory public static class DraftSnapshotFactory
{ {
@@ -29,6 +40,29 @@ public static class DraftSnapshotFactory
/// <param name="ct">Cancellation token.</param> /// <param name="ct">Cancellation token.</param>
/// <returns>A snapshot populated from the live DB, ready for <see cref="DraftValidator.Validate"/>.</returns> /// <returns>A snapshot populated from the live DB, ready for <see cref="DraftValidator.Validate"/>.</returns>
public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default) public static async Task<DraftSnapshot> FromConfigDbAsync(OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(db);
var ownsTransaction = db.Database.IsRelational() && db.Database.CurrentTransaction is null;
var tx = ownsTransaction
? await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot, ct).ConfigureAwait(false)
: null;
await using (tx)
{
var snapshot = await ReadAsync(db, ct).ConfigureAwait(false);
// Read-only: committing releases the snapshot's version-store reservation; nothing was written.
if (tx is not null) await tx.CommitAsync(ct).ConfigureAwait(false);
return snapshot;
}
}
// Every composed table is read ORDERED BY ITS NATURAL KEY, server-side. That order IS the deployment
// artifact's order (ConfigComposer.Compose serialises the snapshot as given): it is exactly the order the
// pre-R03:3.2 composer's own queries produced — the database collation's, case-insensitive on SQL Server —
// so the revision hash of an unchanged configuration is byte-identical across the refactor (fix round 1;
// pinned by ConfigComposerSqlServerHashStabilityTests). Re-sorting in memory with any other comparer would
// re-hash every mixed-case configuration and make drift detection report the whole fleet drifted.
private static async Task<DraftSnapshot> ReadAsync(OtOpcUaConfigDbContext db, CancellationToken ct)
=> new DraftSnapshot => new DraftSnapshot
{ {
// GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the // GenerationId=0 and null Enterprise/Site are intentional conservative fallbacks at the
@@ -36,23 +70,26 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields. // EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it) GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
ServerClusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
ClusterNodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree // v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length, // (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness). // effective-leaf uniqueness).
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct), RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct), DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct), Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct), TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct), UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct), UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct), Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct), Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct), UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct), VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct), ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct), Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct), PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct), NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations ActiveReservations = await db.ExternalIdReservations
.AsNoTracking() .AsNoTracking()
@@ -28,6 +28,12 @@ public sealed class AdminOperationsActor : ReceiveActor
private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType; private readonly IReadOnlyDictionary<string, IDriverProbe> _probesByType;
private readonly TagConfigValidationMode _tagConfigValidationMode; private readonly TagConfigValidationMode _tagConfigValidationMode;
/// <summary>
/// The coordinator's apply deadline. A <c>Dispatching</c> row with no expected-ack rows that is older
/// than this is a lost dispatch, closed by the in-flight check (fix round 1 / I7).
/// </summary>
private readonly TimeSpan _applyDeadline;
/// <summary> /// <summary>
/// Where central→node commands are handed off (per-cluster mesh), or /// Where central→node commands are handed off (per-cluster mesh), or
/// <see langword="null"/> to publish on the mediator directly. /// <see langword="null"/> to publish on the mediator directly.
@@ -36,6 +42,9 @@ public sealed class AdminOperationsActor : ReceiveActor
private readonly ILoggingAdapter _log = Context.GetLogger(); private readonly ILoggingAdapter _log = Context.GetLogger();
/// <summary>The StartDeployment whose compose is running off-actor, or null. See <see cref="HandleStartDeployment"/>.</summary>
private StartDeployment? _composeInFlight;
/// <summary>Creates actor props for the admin operations actor.</summary> /// <summary>Creates actor props for the admin operations actor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param> /// <param name="dbFactory">Factory for creating config database contexts.</param>
/// <param name="coordinator">Reference to the deployment coordinator actor.</param> /// <param name="coordinator">Reference to the deployment coordinator actor.</param>
@@ -45,15 +54,17 @@ public sealed class AdminOperationsActor : ReceiveActor
/// Central comm actor commands are handed to, or <see langword="null"/> to publish on the /// Central comm actor commands are handed to, or <see langword="null"/> to publish on the
/// DistributedPubSub mediator directly (the behaviour before the per-cluster mesh). /// DistributedPubSub mediator directly (the behaviour before the per-cluster mesh).
/// </param> /// </param>
/// <param name="applyDeadline">The coordinator's apply deadline; defaults to <see cref="DeployTimings.ApplyDeadline"/> (the coordinator's default source).</param>
/// <returns>Props configured to create an AdminOperationsActor.</returns> /// <returns>Props configured to create an AdminOperationsActor.</returns>
public static Props Props( public static Props Props(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator, IActorRef coordinator,
IEnumerable<IDriverProbe> probes, IEnumerable<IDriverProbe> probes,
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn, TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn,
IActorRef? meshRouter = null) => IActorRef? meshRouter = null,
TimeSpan? applyDeadline = null) =>
Akka.Actor.Props.Create(() => Akka.Actor.Props.Create(() =>
new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode, meshRouter)); new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode, meshRouter, applyDeadline));
/// <summary>Initializes a new instance of the AdminOperationsActor.</summary> /// <summary>Initializes a new instance of the AdminOperationsActor.</summary>
/// <param name="dbFactory">Factory for creating config database contexts.</param> /// <param name="dbFactory">Factory for creating config database contexts.</param>
@@ -61,27 +72,48 @@ public sealed class AdminOperationsActor : ReceiveActor
/// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param> /// <param name="probes">Driver probes registered in DI; keyed by DriverType (case-insensitive).</param>
/// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param> /// <param name="tagConfigValidationMode">Deploy-gate TagConfig strictness mode (Warn default | Error opt-in).</param>
/// <param name="meshRouter">Central comm actor, or <see langword="null"/> for the mediator.</param> /// <param name="meshRouter">Central comm actor, or <see langword="null"/> for the mediator.</param>
/// <param name="applyDeadline">The coordinator's apply deadline; defaults to <see cref="DeployTimings.ApplyDeadline"/> (the coordinator's default source).</param>
public AdminOperationsActor( public AdminOperationsActor(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
IActorRef coordinator, IActorRef coordinator,
IEnumerable<IDriverProbe> probes, IEnumerable<IDriverProbe> probes,
TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn, TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn,
IActorRef? meshRouter = null) IActorRef? meshRouter = null,
TimeSpan? applyDeadline = null)
{ {
// The same source the coordinator's default reads (ConfigPublishCoordinator.DefaultApplyDeadline IS
// DeployTimings.ApplyDeadline), so the lost-dispatch closure and the coordinator's own deadline agree.
_applyDeadline = applyDeadline ?? DeployTimings.ApplyDeadline;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_coordinator = coordinator; _coordinator = coordinator;
_probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); _probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase);
_tagConfigValidationMode = tagConfigValidationMode; _tagConfigValidationMode = tagConfigValidationMode;
_meshRouter = meshRouter; _meshRouter = meshRouter;
ReceiveAsync<StartDeployment>(HandleStartDeploymentAsync); // Synchronous receives: the compose and the probe run off-actor and come back as messages, so
ReceiveAsync<TestDriverConnect>(HandleTestDriverConnectAsync); // the mailbox is never suspended for the length of a DB snapshot or a PLC connect timeout.
Receive<StartDeployment>(HandleStartDeployment);
Receive<DeployComposed>(HandleDeployComposed);
Receive<DeployComposeFailed>(HandleDeployComposeFailed);
Receive<TestDriverConnect>(HandleTestDriverConnect);
ReceiveAsync<RestartDriver>(HandleRestartDriverAsync); ReceiveAsync<RestartDriver>(HandleRestartDriverAsync);
ReceiveAsync<ReconnectDriver>(HandleReconnectDriverAsync); ReceiveAsync<ReconnectDriver>(HandleReconnectDriverAsync);
Receive<AcknowledgeAlarmCommand>(HandleAcknowledgeAlarm); Receive<AcknowledgeAlarmCommand>(HandleAcknowledgeAlarm);
Receive<ShelveAlarmCommand>(HandleShelveAlarm); Receive<ShelveAlarmCommand>(HandleShelveAlarm);
} }
/// <summary>
/// The reply text for an unexpected server-side failure (R03:4.1): a fixed sentence plus the request's
/// correlation id, never <c>ex.Message</c> — a SqlException or UriFormatException message carries
/// connection-string fragments and host names, and the reply reaches any ConfigEditor browser session.
/// The detail is in the central log under the same correlation id.
/// </summary>
/// <param name="operation">The admin operation that failed.</param>
/// <param name="correlationId">The request's correlation token, as logged alongside the exception.</param>
/// <returns>The operator-facing message.</returns>
internal static string ServerFault(string operation, object correlationId) =>
$"{operation} failed on the server; see the central node log for correlation {correlationId}.";
/// <summary> /// <summary>
/// Hands a central→node command to the mesh transport router, or publishes it directly when /// Hands a central→node command to the mesh transport router, or publishes it directly when
/// no router is wired. /// no router is wired.
@@ -129,8 +161,8 @@ public sealed class AdminOperationsActor : ReceiveActor
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId}", msg.AlarmId); _log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId} (correlation {CorrelationId})", msg.AlarmId, msg.CorrelationId);
replyTo.Tell(new AcknowledgeAlarmResult(false, ex.Message, msg.CorrelationId)); replyTo.Tell(new AcknowledgeAlarmResult(false, ServerFault("Acknowledge", msg.CorrelationId), msg.CorrelationId));
} }
} }
@@ -176,215 +208,364 @@ public sealed class AdminOperationsActor : ReceiveActor
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId}", msg.Kind, msg.AlarmId); _log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId} (correlation {CorrelationId})", msg.Kind, msg.AlarmId, msg.CorrelationId);
replyTo.Tell(new ShelveAlarmResult(false, ex.Message, msg.CorrelationId)); replyTo.Tell(new ShelveAlarmResult(false, ServerFault("Shelve", msg.CorrelationId), msg.CorrelationId));
} }
} }
private async Task HandleStartDeploymentAsync(StartDeployment msg) /// <summary>
/// <c>StartDeployment</c>. The only serialised part is the actor-local <see cref="_composeInFlight"/>
/// flag; the DB in-flight check, the snapshot read, validation, gzip and the row write all run on the
/// thread pool and come back as a <see cref="DeployComposed"/> / <see cref="DeployComposeFailed"/>
/// message, so a slow or blocked ConfigDb never suspends the mailbox (R03:3.1; fix round 1 moved the
/// in-flight query off the actor thread too) and alarm acks keep flowing while it runs.
/// </summary>
/// <remarks>
/// <see cref="_composeInFlight"/> is what keeps the check correct once it is off-actor: it is set
/// BEFORE the off-actor work starts and cleared when the outcome message lands (both branches), so a
/// second request is refused in memory for the whole check-plus-compose and two artifacts can never
/// race for one coordinator.
/// </remarks>
private void HandleStartDeployment(StartDeployment msg)
{ {
var replyTo = Sender; var replyTo = Sender;
if (_composeInFlight is { } inFlightRequest)
{
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.AnotherDeploymentInFlight,
DeploymentId: null,
RevisionHash: null,
Message: $"A deployment requested by {inFlightRequest.CreatedBy} is still being composed.",
msg.CorrelationId));
return;
}
_composeInFlight = msg;
// Captured here: Context/Sender are not available on the thread-pool thread.
var sourceNode = Akka.Cluster.Cluster.Get(Context.System).SelfAddress.Host ?? "unknown";
var self = Self;
Task.Run(async () =>
{
var refusal = await CheckInFlightAsync(msg).ConfigureAwait(false);
return refusal ?? await ComposeDeploymentAsync(msg, sourceNode).ConfigureAwait(false);
})
.PipeTo(self,
success: composed => new DeployComposed(msg, replyTo, composed.Result, composed.Dispatch),
failure: ex => new DeployComposeFailed(msg, replyTo, ex));
}
/// <summary>
/// The DB half of the in-flight check, off the actor thread. Refuses while a deployment is in flight —
/// keeping the coordinator's state unambiguous — EXCEPT a lost dispatch, which it closes first.
/// </summary>
/// <param name="msg">The request.</param>
/// <returns>The refusal, or null to proceed.</returns>
/// <remarks>
/// <para>
/// <b>Lost dispatch (fix round 1 / I7).</b> A <c>Dispatching</c> row with ZERO
/// <c>NodeDeploymentState</c> rows was never taken by the coordinator (its <c>HandleDispatch</c>
/// seeds the expected set and flips the status in one <c>SaveChanges</c>). The coordinator's PreStart
/// recovers such a row — but only one that already existed when it started. A row committed AFTER
/// PreStart whose <c>Tell</c> never arrived (this actor died between the compose's commit and
/// <see cref="HandleDeployComposed"/>) has no owner at all, and refused every later deploy forever.
/// Once it is past the apply deadline nothing can still be legitimately dispatching it, so it is
/// closed <c>TimedOut</c> with a reason and the new deploy proceeds. A late <c>DispatchDeployment</c>
/// for it is ignored by the coordinator's terminal-state guard.
/// </para>
/// <para>
/// One indexed projection (<c>IX_Deployment_Status</c>) plus, only for a Dispatching row past the
/// deadline, a count and a tracked update.
/// </para>
/// </remarks>
private async Task<ComposeOutcome?> CheckInFlightAsync(StartDeployment msg)
{
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var inflight = await db.Deployments
.AsNoTracking()
.Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => new { d.DeploymentId, d.Status, d.CreatedAtUtc })
.ToListAsync()
.ConfigureAwait(false);
foreach (var row in inflight)
{
if (row.Status == DeploymentStatus.Dispatching
&& DateTime.UtcNow - row.CreatedAtUtc > _applyDeadline
&& !await db.NodeDeploymentStates.AnyAsync(s => s.DeploymentId == row.DeploymentId).ConfigureAwait(false)
&& await CloseLostDispatchAsync(db, row.DeploymentId).ConfigureAwait(false))
{
continue;
}
return new ComposeOutcome(
new StartDeploymentResult(
StartDeploymentOutcome.AnotherDeploymentInFlight,
DeploymentId: new DeploymentId(row.DeploymentId),
RevisionHash: null,
Message: $"Deployment {row.DeploymentId:N} is still in flight.",
msg.CorrelationId),
Dispatch: null);
}
return null;
}
/// <summary>Closes a lost dispatch as TimedOut, unless it moved on meanwhile (optimistic, on its RowVersion).</summary>
/// <param name="db">The context.</param>
/// <param name="deploymentId">The row.</param>
/// <returns><see langword="true"/> when the row is now closed.</returns>
private async Task<bool> CloseLostDispatchAsync(OtOpcUaConfigDbContext db, Guid deploymentId)
{
var stamp = await db.Deployments
.AsNoTracking()
.Where(d => d.DeploymentId == deploymentId && d.Status == DeploymentStatus.Dispatching)
.Select(d => new { d.RowVersion })
.FirstOrDefaultAsync()
.ConfigureAwait(false);
if (stamp is null) return true; // no longer Dispatching — not in flight as far as this check goes
// Stub update: the row's ArtifactBlob is never read just to change its status.
var stub = new Deployment
{
DeploymentId = deploymentId, RevisionHash = string.Empty, CreatedBy = string.Empty,
RowVersion = stamp.RowVersion,
Status = DeploymentStatus.TimedOut,
FailureReason =
"Dispatch was lost before any node was expected to ack (the coordinator was never told, or never " +
"took it) and the apply deadline has passed; closed by the next StartDeployment. Redeploy.",
};
var entry = db.Deployments.Attach(stub);
entry.Property(d => d.Status).IsModified = true;
entry.Property(d => d.FailureReason).IsModified = true;
try try
{ {
await using var db = await _dbFactory.CreateDbContextAsync(); await db.SaveChangesAsync().ConfigureAwait(false);
}
catch (DbUpdateConcurrencyException)
{
// The coordinator took it after all (status flipped, RowVersion moved): it is genuinely in flight.
return false;
}
_log.Warning(
"StartDeployment: closed lost dispatch {DeploymentId} as TimedOut — Dispatching with no expected-ack rows " +
"past the {Deadline} apply deadline (the coordinator never took it)", deploymentId, _applyDeadline);
return true;
}
// Refuse if any deployment is already in flight — keeps the coordinator's state private void HandleDeployComposed(DeployComposed msg)
// unambiguous. The UI is expected to wait for the in-flight one to seal or fail. {
var inflight = await db.Deployments _composeInFlight = null;
.Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks) // The row is committed (the compose task completed), so the coordinator can be told now; it reads
.Select(d => d.DeploymentId) // the row back by id when it seeds the expected-ack set.
.FirstOrDefaultAsync(); if (msg.Dispatch is { } dispatch) _coordinator.Tell(dispatch);
if (inflight != Guid.Empty) msg.ReplyTo.Tell(msg.Result);
{ }
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.AnotherDeploymentInFlight,
DeploymentId: new DeploymentId(inflight),
RevisionHash: null,
Message: $"Deployment {inflight:N} is still in flight.",
msg.CorrelationId));
return;
}
// Full pre-seal gate: run the complete DraftValidator (one pass, all rules) and reject private void HandleDeployComposeFailed(DeployComposeFailed msg)
// on ANY validation error. The config is now canonical — the company overlay loader emits {
// canonical EquipmentIds and the seed is clean — so every rule (UNS segments, EquipmentId _composeInFlight = null;
// derivation, cross-cluster/namespace binding, driver-namespace compat, signal collisions, _log.Error(msg.Error, "StartDeployment failed for {CreatedBy} (correlation {CorrelationId})", msg.Request.CreatedBy, msg.Request.CorrelationId);
// …) gates the deploy. A green build in this repo does not prove the config is valid; this msg.ReplyTo.Tell(new StartDeploymentResult(
// is the last guard before a bad address space (or a non-derived EquipmentId) ships. StartDeploymentOutcome.Rejected,
// NOTE: a BadDuplicateExternalIdentifier rejection from the reservation pre-flight can DeploymentId: null,
// (rarely) be a false positive if an external-id reservation release has not yet been RevisionHash: null,
// committed/visible when the snapshot is read — operators seeing a spurious one should Message: ServerFault("StartDeployment", msg.Request.CorrelationId),
// check ExternalIdReservation state before re-submitting. msg.Request.CorrelationId));
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db); }
var errors = DraftValidator.Validate(draft).ToList();
// Cluster-topology guard. The SQL /// <summary>Outcome of the off-actor compose: the reply to send, and the dispatch to issue when Accepted.</summary>
// CK_ServerCluster_RedundancyMode_NodeCount CHECK enforces the (NodeCount, RedundancyMode) private sealed record ComposeOutcome(StartDeploymentResult Result, DispatchDeployment? Dispatch);
// pair on the row itself, but it cannot see the per-node ClusterNode.Enabled flag — an
// operator can disable a node (effective enabled-count = 1) while leaving RedundancyMode at
// Hot/Warm and the constraint stays green, which would boot the runtime into an
// InvalidTopology band. ValidateClusterTopology catches that drift, but it isn't carried on
// the generation-versioned DraftSnapshot (the cluster/node rows aren't versioned), so it must
// be run separately here against the live rows. Read-only (AsNoTracking); errors fold into the
// same reject summary alongside the snapshot rules so a deploy failing either check is
// rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
var clusters = await db.ServerClusters
.AsNoTracking()
.OrderBy(c => c.ClusterId)
.ToListAsync();
var nodesByCluster = (await db.ClusterNodes
.AsNoTracking()
.ToListAsync())
.GroupBy(n => n.ClusterId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
foreach (var cluster in clusters)
{
var nodes = nodesByCluster.TryGetValue(cluster.ClusterId, out var ns)
? (IReadOnlyList<ClusterNode>)ns
: [];
errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes));
}
// Deploy-time TagConfig strictness inspection over equipment tags. The private sealed record DeployComposed(
// driver-typed EquipmentTagConfigInspector surfaces every blob the driver's own reader would StartDeployment Request, IActorRef ReplyTo, StartDeploymentResult Result, DispatchDeployment? Dispatch);
// REJECT — a present-but-invalid enum value, an absent or malformed address field, a
// structurally-unparseable TagConfig — each of which makes the driver skip the tag so it reads
// BadNodeIdUnknown forever. (The runtime used to silently default a typo'd enum;
// it now rejects the tag, so these warnings describe rejections.) In Warn mode
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
// Running servers are untouched either way — the gate only sees re-deploys.
// v3: a raw Tag's driver is resolved via its Device (Tag no longer carries EquipmentId /
// DriverInstanceId). Inspect EVERY raw tag's TagConfig against its device's driver type.
var driverTypeById = draft.DriverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
{
if (_tagConfigValidationMode == TagConfigValidationMode.Error)
errors.AddRange(tagConfigWarnings.Select(x => new ValidationError("TagConfigInvalid", x.Text, x.TagId)));
else
foreach (var x in tagConfigWarnings)
_log.Warning("StartDeployment: TagConfig warning (Warn mode, non-blocking): {Warning}", x.Text);
}
if (errors.Count > 0) private sealed record DeployComposeFailed(StartDeployment Request, IActorRef ReplyTo, Exception Error);
{
var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")); /// <summary>
_log.Warning("StartDeployment rejected ({Count} validation error(s)): {Summary}", /// The deploy compose, off the actor thread. Touches no actor state: everything it needs is in
errors.Count, summary); /// <paramref name="msg"/>, <paramref name="sourceNode"/> and the readonly fields captured at
replyTo.Tell(new StartDeploymentResult( /// construction (<see cref="_dbFactory"/>, <see cref="_tagConfigValidationMode"/>, <see cref="_log"/> —
/// Akka's logging adapter is thread-safe).
/// </summary>
private async Task<ComposeOutcome> ComposeDeploymentAsync(StartDeployment msg, string sourceNode)
{
await using var db = await _dbFactory.CreateDbContextAsync();
// Full pre-seal gate: run the complete DraftValidator (one pass, all rules) and reject
// on ANY validation error. The config is now canonical — the company overlay loader emits
// canonical EquipmentIds and the seed is clean — so every rule (UNS segments, EquipmentId
// derivation, cross-cluster/namespace binding, driver-namespace compat, signal collisions,
// …) gates the deploy. A green build in this repo does not prove the config is valid; this
// is the last guard before a bad address space (or a non-derived EquipmentId) ships.
// NOTE: a BadDuplicateExternalIdentifier rejection from the reservation pre-flight can
// (rarely) be a false positive if an external-id reservation release has not yet been
// committed/visible when the snapshot is read — operators seeing a spurious one should
// check ExternalIdReservation state before re-submitting.
var draft = await DraftSnapshotFactory.FromConfigDbAsync(db);
var errors = DraftValidator.Validate(draft).ToList();
// Cluster-topology guard. The SQL
// CK_ServerCluster_RedundancyMode_NodeCount CHECK enforces the (NodeCount, RedundancyMode)
// pair on the row itself, but it cannot see the per-node ClusterNode.Enabled flag — an
// operator can disable a node (effective enabled-count = 1) while leaving RedundancyMode at
// Hot/Warm and the constraint stays green, which would boot the runtime into an
// InvalidTopology band. ValidateClusterTopology catches that drift; it is not part of
// DraftValidator.Validate, so it runs separately here over the snapshot's cluster/node rows.
// Errors fold into the same reject summary alongside the snapshot rules so a deploy failing
// either check is rejected with both sets of messages. ClusterId-ordered for a deterministic summary.
// R03:3.2 — both come off the SAME snapshot read as every other table (one SNAPSHOT
// transaction), not a second query that could observe a later edit.
var clusters = draft.ServerClusters.OrderBy(c => c.ClusterId, StringComparer.Ordinal).ToList();
var nodesByCluster = draft.ClusterNodes
.GroupBy(n => n.ClusterId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
foreach (var cluster in clusters)
{
var nodes = nodesByCluster.TryGetValue(cluster.ClusterId, out var ns)
? (IReadOnlyList<ClusterNode>)ns
: [];
errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes));
}
// Deploy-time TagConfig strictness inspection over equipment tags. The
// driver-typed EquipmentTagConfigInspector surfaces every blob the driver's own reader would
// REJECT — a present-but-invalid enum value, an absent or malformed address field, a
// structurally-unparseable TagConfig — each of which makes the driver skip the tag so it reads
// BadNodeIdUnknown forever. (The runtime used to silently default a typo'd enum;
// it now rejects the tag, so these warnings describe rejections.) In Warn mode
// (default) these are non-blocking (logged + appended to the accepted result Message); in Error
// mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed.
// Running servers are untouched either way — the gate only sees re-deploys.
// v3: a raw Tag's driver is resolved via its Device (Tag no longer carries EquipmentId /
// DriverInstanceId). Inspect EVERY raw tag's TagConfig against its device's driver type.
var driverTypeById = draft.DriverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
var driverByDevice = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DriverInstanceId, StringComparer.Ordinal);
var deviceConfigById = draft.Devices
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().DeviceConfig, StringComparer.Ordinal);
var tagConfigWarnings = new List<(string TagId, string Text)>();
foreach (var t in draft.Tags)
{
if (!driverByDevice.TryGetValue(t.DeviceId, out var driverInstanceId)) continue;
if (!driverTypeById.TryGetValue(driverInstanceId, out var driverType)) continue;
foreach (var w in EquipmentTagConfigInspector.Inspect(
driverType, t.TagConfig, deviceConfigById.GetValueOrDefault(t.DeviceId)))
tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}': {w}"));
}
if (tagConfigWarnings.Count > 0)
{
if (_tagConfigValidationMode == TagConfigValidationMode.Error)
errors.AddRange(tagConfigWarnings.Select(x => new ValidationError("TagConfigInvalid", x.Text, x.TagId)));
else
foreach (var x in tagConfigWarnings)
_log.Warning("StartDeployment: TagConfig warning (Warn mode, non-blocking): {Warning}", x.Text);
}
if (errors.Count > 0)
{
var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}"));
_log.Warning("StartDeployment rejected ({Count} validation error(s)): {Summary}",
errors.Count, summary);
return new ComposeOutcome(
new StartDeploymentResult(
StartDeploymentOutcome.Rejected, StartDeploymentOutcome.Rejected,
DeploymentId: null, DeploymentId: null,
RevisionHash: null, RevisionHash: null,
Message: summary, Message: summary,
msg.CorrelationId)); msg.CorrelationId),
return; Dispatch: null);
}
// Warn-only compile-cost guardrail (NEVER blocks). Each genuinely-compiled (non-passthrough)
// script costs ~1.66 MiB RSS per node to materialize via Roslyn (measured post-A0; design doc
// 2026-06-07). Passthrough "mirror" scripts compile to ~nothing, so they are excluded; distinct
// sources are counted (the compile cache keys on source, so duplicates collapse to one unit).
// This only surfaces the estimate to the operator — the DraftValidator gate above is the hard
// reject; this advisory rides in the Accepted Message so the UI can show it.
// advisory is declared outside the inner try so the Accepted reply below can still read it even
// when the guardrail throws. A failed estimate must NEVER block a valid deploy — the caller's
// failure branch is reserved for genuine seal/save failures.
string? advisory = null;
try
{
const double PerScriptMiB = 1.66; // measured post-A0 per-script RSS (design doc 2026-06-07)
// Projected from the snapshot already in hand — Script.SourceCode used to be read three times.
var compiled = draft.Scripts
.Select(s => s.SourceCode)
.Where(src => !PassthroughScript.TryMatch(src, out _))
.Distinct(StringComparer.Ordinal)
.Count();
if (compiled > 0)
{
var estMiB = compiled * PerScriptMiB;
_log.Warning(
"StartDeployment: {Compiled} script(s) will compile (~{EstMiB:F0} MiB RSS per node); ensure node mem_limit covers it",
compiled, estMiB);
advisory = $"{compiled} script(s) will compile (~{estMiB:F0} MiB/node)";
} }
}
catch (Exception ex)
{
// Guardrail is advisory-only — a failed estimate must never block a valid deploy.
_log.Warning(ex, "StartDeployment: script compile-cost estimate failed; advisory skipped");
advisory = null;
}
// Warn-only compile-cost guardrail (NEVER blocks). Each genuinely-compiled (non-passthrough) // Composed from the validated snapshot, never from a second read: an edit that lands between
// script costs ~1.66 MiB RSS per node to materialize via Roslyn (measured post-A0; design doc // validation and here is simply not in this artifact (R03:3.2).
// 2026-06-07). Passthrough "mirror" scripts compile to ~nothing, so they are excluded; distinct var artifact = ConfigComposer.Compose(draft);
// sources are counted (the compile cache keys on source, so duplicates collapse to one unit). var deploymentId = DeploymentId.NewId();
// This only surfaces the estimate to the operator — the DraftValidator gate above is the hard var revHash = RevisionHash.Parse(artifact.RevisionHash);
// reject; this advisory rides in the Accepted Message so the UI can show it.
// advisory is declared outside the inner try so the Accepted reply below can still read it even
// when the guardrail query throws (e.g. transient SQL). A failed estimate must NEVER block a
// valid deploy — the outer catch is reserved for genuine seal/save failures.
string? advisory = null;
try
{
const double PerScriptMiB = 1.66; // measured post-A0 per-script RSS (design doc 2026-06-07)
var scriptSources = await db.Scripts.AsNoTracking().Select(s => s.SourceCode).ToListAsync();
var compiled = scriptSources
.Where(src => !PassthroughScript.TryMatch(src, out _))
.Distinct(StringComparer.Ordinal)
.Count();
if (compiled > 0)
{
var estMiB = compiled * PerScriptMiB;
_log.Warning(
"StartDeployment: {Compiled} script(s) will compile (~{EstMiB:F0} MiB RSS per node); ensure node mem_limit covers it",
compiled, estMiB);
advisory = $"{compiled} script(s) will compile (~{estMiB:F0} MiB/node)";
}
}
catch (Exception ex)
{
// Guardrail is advisory-only — a failed estimate must never block a valid deploy.
_log.Warning(ex, "StartDeployment: script compile-cost estimate failed; advisory skipped");
advisory = null;
}
var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db); db.Deployments.Add(new Deployment
var deploymentId = DeploymentId.NewId(); {
var revHash = RevisionHash.Parse(artifact.RevisionHash); DeploymentId = deploymentId.Value,
RevisionHash = artifact.RevisionHash,
Status = DeploymentStatus.Dispatching,
CreatedBy = msg.CreatedBy,
ArtifactBlob = artifact.Blob,
});
db.Deployments.Add(new Deployment // Marker ConfigEdit row so the audit timeline shows the deployment snapshot.
{ db.ConfigEdits.Add(new ConfigEdit
DeploymentId = deploymentId.Value, {
RevisionHash = artifact.RevisionHash, EntityType = "Deployment",
Status = DeploymentStatus.Dispatching, EntityId = deploymentId.Value,
CreatedBy = msg.CreatedBy, FieldsJson = $"{{\"revisionHash\":\"{artifact.RevisionHash}\",\"sizeBytes\":{artifact.Blob.Length}}}",
ArtifactBlob = artifact.Blob, EditedBy = msg.CreatedBy,
}); SourceNode = sourceNode,
});
// Marker ConfigEdit row so the audit timeline shows the deployment snapshot. await db.SaveChangesAsync();
db.ConfigEdits.Add(new ConfigEdit
{
EntityType = "Deployment",
EntityId = deploymentId.Value,
FieldsJson = $"{{\"revisionHash\":\"{artifact.RevisionHash}\",\"sizeBytes\":{artifact.Blob.Length}}}",
EditedBy = msg.CreatedBy,
SourceNode = Akka.Cluster.Cluster.Get(Context.System).SelfAddress.Host ?? "unknown",
});
await db.SaveChangesAsync(); // Accepted message = the compile-cost advisory (if any) + any Warn-mode TagConfig warnings, so
// the operator sees the non-blocking strictness findings on a successful deploy.
var messageParts = new List<string>();
if (advisory is not null) messageParts.Add(advisory);
messageParts.AddRange(tagConfigWarnings.Select(x => x.Text));
var acceptedMessage = messageParts.Count > 0 ? string.Join("; ", messageParts) : null;
_coordinator.Tell(new DispatchDeployment(deploymentId, revHash, msg.CorrelationId)); return new ComposeOutcome(
new StartDeploymentResult(
// Accepted message = the compile-cost advisory (if any) + any Warn-mode TagConfig warnings, so
// the operator sees the non-blocking strictness findings on a successful deploy.
var messageParts = new List<string>();
if (advisory is not null) messageParts.Add(advisory);
messageParts.AddRange(tagConfigWarnings.Select(x => x.Text));
var acceptedMessage = messageParts.Count > 0 ? string.Join("; ", messageParts) : null;
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.Accepted, StartDeploymentOutcome.Accepted,
deploymentId, deploymentId,
revHash, revHash,
Message: acceptedMessage, Message: acceptedMessage,
msg.CorrelationId)); msg.CorrelationId),
} new DispatchDeployment(deploymentId, revHash, msg.CorrelationId));
catch (Exception ex)
{
_log.Error(ex, "StartDeployment failed for {CreatedBy}", msg.CreatedBy);
replyTo.Tell(new StartDeploymentResult(
StartDeploymentOutcome.Rejected,
DeploymentId: null,
RevisionHash: null,
Message: ex.Message,
msg.CorrelationId));
}
} }
private async Task HandleTestDriverConnectAsync(TestDriverConnect msg) /// <summary>
/// Test Connect runs on the thread pool and pipes its reply straight to the requester: a probe
/// against a black-holed PLC (60 s clamp) must not suspend the singleton's mailbox (R03:3.1). Every
/// path inside the task yields a <see cref="TestDriverConnectResult"/>, so the requester never
/// sees a <c>Status.Failure</c>.
/// </summary>
private void HandleTestDriverConnect(TestDriverConnect msg)
{ {
var replyTo = Sender; var replyTo = Sender;
if (!_probesByType.TryGetValue(msg.DriverType, out var probe)) if (!_probesByType.TryGetValue(msg.DriverType, out var probe))
@@ -397,6 +578,11 @@ public sealed class AdminOperationsActor : ReceiveActor
return; return;
} }
Task.Run(() => ProbeAsync(probe, msg)).PipeTo(replyTo);
}
private async Task<TestDriverConnectResult> ProbeAsync(IDriverProbe probe, TestDriverConnect msg)
{
var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60); var clampedSec = Math.Clamp(msg.TimeoutSeconds, 1, 60);
var timeout = TimeSpan.FromSeconds(clampedSec); var timeout = TimeSpan.FromSeconds(clampedSec);
using var cts = new CancellationTokenSource(timeout); using var cts = new CancellationTokenSource(timeout);
@@ -406,28 +592,28 @@ public sealed class AdminOperationsActor : ReceiveActor
{ {
var result = await probe.ProbeAsync(msg.ConfigJson, timeout, cts.Token); var result = await probe.ProbeAsync(msg.ConfigJson, timeout, cts.Token);
sw.Stop(); sw.Stop();
replyTo.Tell(new TestDriverConnectResult( return new TestDriverConnectResult(
result.Ok, result.Ok,
result.Message, result.Message,
result.Ok ? sw.Elapsed.TotalMilliseconds : (double?)null, result.Ok ? sw.Elapsed.TotalMilliseconds : (double?)null,
msg.CorrelationId)); msg.CorrelationId);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
replyTo.Tell(new TestDriverConnectResult( return new TestDriverConnectResult(
false, false,
$"Probe timed out after {clampedSec}s.", $"Probe timed out after {clampedSec}s.",
null, null,
msg.CorrelationId)); msg.CorrelationId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "Probe for {DriverType} threw", msg.DriverType); _log.Error(ex, "Probe for {DriverType} threw (correlation {CorrelationId})", msg.DriverType, msg.CorrelationId);
replyTo.Tell(new TestDriverConnectResult( return new TestDriverConnectResult(
false, false,
ex.Message, ServerFault("Test Connect", msg.CorrelationId),
null, null,
msg.CorrelationId)); msg.CorrelationId);
} }
} }
@@ -457,8 +643,8 @@ public sealed class AdminOperationsActor : ReceiveActor
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId}", msg.DriverInstanceId); _log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId);
replyTo.Tell(new RestartDriverResult(false, ex.Message, msg.CorrelationId)); replyTo.Tell(new RestartDriverResult(false, ServerFault("RestartDriver", msg.CorrelationId), msg.CorrelationId));
} }
} }
@@ -487,8 +673,8 @@ public sealed class AdminOperationsActor : ReceiveActor
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId}", msg.DriverInstanceId); _log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId);
replyTo.Tell(new ReconnectDriverResult(false, ex.Message, msg.CorrelationId)); replyTo.Tell(new ReconnectDriverResult(false, ServerFault("ReconnectDriver", msg.CorrelationId), msg.CorrelationId));
} }
} }
} }
@@ -1,17 +1,17 @@
using System.IO.Compression; using System.IO.Compression;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
/// <summary> /// <summary>
/// Pure snapshot composer: reads the current live-edit state from <see cref="OtOpcUaConfigDbContext"/> /// Pure snapshot composer: reads the current live-edit state from <see cref="OtOpcUaConfigDbContext"/>
/// and serialises it into a deterministic, gzip-compressed byte[] artifact + SHA-256 hex revision hash. /// and serialises it into a deterministic, gzip-compressed byte[] artifact + SHA-256 hex revision hash.
/// Determinism comes from sorting every collection by its natural key before serialising, so two snapshots /// Determinism comes from reading every collection ordered by its natural key on the server
/// over the same DB state always produce the same hash regardless of EF row ordering; compression wraps the /// (<see cref="DraftSnapshotFactory"/>), so two snapshots over the same DB state always produce the same hash
/// — and the same hash as the pre-R03:3.2 composer, which used those same queries; compression wraps the
/// final bytes and reorders nothing. The hash is taken over the COMPRESSED bytes — see <see cref="Pack"/>. /// final bytes and reorders nothing. The hash is taken over the COMPRESSED bytes — see <see cref="Pack"/>.
/// </summary> /// </summary>
public static class ConfigComposer public static class ConfigComposer
@@ -25,45 +25,71 @@ public static class ConfigComposer
}; };
/// <summary>Reads the current configuration and returns a deterministic snapshot blob with revision hash.</summary> /// <summary>Reads the current configuration and returns a deterministic snapshot blob with revision hash.</summary>
/// <remarks>
/// One consistent read (<see cref="DraftSnapshotFactory.FromConfigDbAsync"/>, a SNAPSHOT transaction on
/// SQL Server) handed to <see cref="Compose"/>. The deploy path does not call this: it validates and
/// composes from the one <see cref="DraftSnapshot"/> it already holds, so nothing can land between the
/// validator's read and the composer's. This overload serves the drift probe and tests.
/// </remarks>
/// <param name="db">The configuration database context.</param> /// <param name="db">The configuration database context.</param>
/// <param name="ct">The cancellation token for the operation.</param> /// <param name="ct">The cancellation token for the operation.</param>
/// <returns>A deterministic <see cref="ConfigArtifact"/> containing the serialised snapshot bytes and its revision hash.</returns> /// <returns>A deterministic <see cref="ConfigArtifact"/> containing the serialised snapshot bytes and its revision hash.</returns>
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync( public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default) OtOpcUaConfigDbContext db, CancellationToken ct = default)
=> Compose(await DraftSnapshotFactory.FromConfigDbAsync(db, ct).ConfigureAwait(false));
/// <summary>
/// Serialises an already-read <see cref="DraftSnapshot"/> into the deterministic artifact. Pure over
/// its input: the same snapshot always yields the same bytes and hash, whatever the DB holds by now.
/// </summary>
/// <remarks>
/// Serialises the collections IN THE ORDER GIVEN. Determinism comes from
/// <see cref="DraftSnapshotFactory.FromConfigDbAsync"/>, which reads every table ordered by its natural key
/// on the server; a snapshot built any other way must be ordered the same way or its hash will differ.
/// </remarks>
/// <param name="snapshot">The rows to compose — the SAME rows the validator was run over.</param>
/// <returns>The compressed artifact blob and its revision hash.</returns>
public static ConfigArtifact Compose(DraftSnapshot snapshot)
{ {
ArgumentNullException.ThrowIfNull(snapshot);
// v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig) // v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
// → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS // → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
// projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/ // projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
// PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit). // PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
// RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256. // RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
var snapshot = new // NO re-sort here (fix round 1): the snapshot's collections arrive ordered by their natural key from
// DraftSnapshotFactory's server-side ORDER BY — the same order, and so the same bytes and RevisionHash,
// as the pre-R03:3.2 composer's own queries. An in-memory ordinal re-sort orders mixed-case keys
// differently from the SQL collation and silently re-hashed every unchanged configuration.
var ordered = new
{ {
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct), Clusters = snapshot.ServerClusters,
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct), Nodes = snapshot.ClusterNodes,
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct), RawFolders = snapshot.RawFolders,
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct), DriverInstances = snapshot.DriverInstances,
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct), Devices = snapshot.Devices,
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct), TagGroups = snapshot.TagGroups,
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct), Equipment = snapshot.Equipment,
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct), Tags = snapshot.Tags,
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct), UnsTagReferences = snapshot.UnsTagReferences,
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct), PollGroups = snapshot.PollGroups,
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct), UnsAreas = snapshot.UnsAreas,
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct), UnsLines = snapshot.UnsLines,
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct), NodeAcls = snapshot.NodeAcls,
// the data-plane credentials travel with the config, because a driver-only node // the data-plane credentials travel with the config, because a driver-only node
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every // has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the // UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob // pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
// still cannot be turned into a working credential. This is also why revoking a key reaches a // still cannot be turned into a working credential. This is also why revoking a key reaches a
// driver-only node only on the next deployment; see the ApiKey entity's remarks. // driver-only node only on the next deployment; see the ApiKey entity's remarks.
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct), ApiKeys = snapshot.ApiKeys,
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct), Scripts = snapshot.Scripts,
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct), VirtualTags = snapshot.VirtualTags,
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct), ScriptedAlarms = snapshot.ScriptedAlarms,
}; };
return Pack(JsonSerializer.SerializeToUtf8Bytes(snapshot, JsonOptions)); return Pack(JsonSerializer.SerializeToUtf8Bytes(ordered, JsonOptions));
} }
/// <summary> /// <summary>
@@ -17,8 +17,8 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
/// Dedup is two-layer: in-buffer (the <see cref="Dictionary{TKey, TValue}"/> below collapses /// Dedup is two-layer: in-buffer (the <see cref="Dictionary{TKey, TValue}"/> below collapses
/// duplicate EventIds before flush) and at the database via the filtered unique index /// duplicate EventIds before flush) and at the database via the filtered unique index
/// <c>UX_ConfigAuditLog_EventId</c> (cross-restart safety — a retry of an already-flushed /// <c>UX_ConfigAuditLog_EventId</c> (cross-restart safety — a retry of an already-flushed
/// batch hits the constraint and we drop the duplicate insert without losing the rest of /// batch hits the constraint; <see cref="SaveBatch"/> drops exactly the already-persisted EventIds
/// the batch). /// and re-saves the rest, so the duplicate is dropped without losing the rest of the batch).
/// ///
/// Implements the shared <see cref="IAuditWriter"/> seam: <see cref="WriteAsync"/> is a /// Implements the shared <see cref="IAuditWriter"/> seam: <see cref="WriteAsync"/> is a
/// best-effort, never-throwing entry point that simply <c>Tell</c>s this actor and returns /// best-effort, never-throwing entry point that simply <c>Tell</c>s this actor and returns
@@ -92,23 +92,8 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
try try
{ {
using var db = _dbFactory.CreateDbContext(); var dropped = SaveBatch(snapshot);
foreach (var evt in snapshot) _log.Debug("AuditWriter flushed {Count} events ({Dropped} rejected rows dropped)", snapshot.Count - dropped, dropped);
{
db.ConfigAuditLogs.Add(new ConfigAuditLog
{
Timestamp = evt.OccurredAtUtc.UtcDateTime,
Principal = evt.Actor,
EventType = $"{evt.Category}:{evt.Action}",
NodeId = evt.SourceNode,
DetailsJson = evt.DetailsJson,
EventId = evt.EventId,
CorrelationId = evt.CorrelationId,
Outcome = evt.Outcome.ToString(),
});
}
db.SaveChanges();
_log.Debug("AuditWriter flushed {Count} events", snapshot.Count);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -116,6 +101,102 @@ public sealed class AuditWriterActor : ReceiveActor, IWithTimers, IAuditWriter
} }
} }
private static ConfigAuditLog ToRow(AuditEvent evt) => new()
{
Timestamp = evt.OccurredAtUtc.UtcDateTime,
Principal = evt.Actor,
EventType = $"{evt.Category}:{evt.Action}",
NodeId = evt.SourceNode,
DetailsJson = evt.DetailsJson,
EventId = evt.EventId,
CorrelationId = evt.CorrelationId,
Outcome = evt.Outcome.ToString(),
};
/// <summary>
/// Persists one flush, dropping ONLY the rows the database will not take (R03:2.6, fix round 1 / I4).
/// </summary>
/// <param name="batch">The flush.</param>
/// <returns>How many rows were dropped.</returns>
/// <remarks>
/// <para>
/// <b>Why not detach <c>ex.Entries</c>.</b> SQL Server inserts the batch as one statement batch, so
/// the <see cref="DbUpdateException"/> raised for ONE duplicate <c>EventId</c> names EVERY entry in
/// the batch. Detaching what it names dropped the whole flush — the exact loss the fix was for
/// (pinned against a real SQL Server by <c>On_sql_server_a_duplicate_event_id_costs_only_that_row</c>).
/// </para>
/// <para>
/// So, on a rejection that names entries (a constraint violation; 2601/2627 for the unique index):
/// (1) ask the database which of the batch's EventIds it already holds and drop exactly those —
/// the at-least-once re-delivery the dedup contract expects; (2) re-save the rest as a batch; (3) if
/// that is still rejected (some OTHER per-row constraint), fall back to row-by-row and drop only the
/// rows that fail on their own. Every attempt uses a fresh context and fresh entities, so no state
/// from a failed save leaks into the next one. A failure that names no entry (connection loss,
/// deadlock) propagates — the whole-batch failure it always was. Bounded: at most 2 + N saves.
/// </para>
/// </remarks>
private int SaveBatch(IReadOnlyList<AuditEvent> batch)
{
if (TrySave(batch, out var first)) return 0;
_log.Debug(first, "AuditWriter: a batch of {Count} was rejected; dropping only already-persisted EventIds", batch.Count);
HashSet<Guid> existing;
using (var db = _dbFactory.CreateDbContext())
{
var ids = batch.Select(e => (Guid?)e.EventId).ToList();
existing = db.ConfigAuditLogs
.AsNoTracking()
.Where(r => ids.Contains(r.EventId))
.Select(r => r.EventId!.Value)
.ToHashSet();
}
foreach (var id in existing)
_log.Debug("AuditWriter: EventId {EventId} already persisted (unique index); dropping the duplicate", id);
var remaining = batch.Where(e => !existing.Contains(e.EventId)).ToList();
var dropped = batch.Count - remaining.Count;
if (remaining.Count == 0 || TrySave(remaining, out _)) return dropped;
// Still rejected with no duplicate left to blame: isolate the offending row(s).
foreach (var evt in remaining)
{
if (TrySave([evt], out var rowError)) continue;
dropped++;
if (IsUniqueViolation(rowError!))
_log.Debug("AuditWriter: EventId {EventId} already persisted (unique index); dropping the duplicate", evt.EventId);
else
_log.Warning(rowError, "AuditWriter: the database rejected EventId {EventId}; dropping that row and keeping the batch", evt.EventId);
}
return dropped;
}
/// <summary>Saves <paramref name="events"/> on a fresh context; a rejection that names entries is reported, anything else throws.</summary>
/// <param name="events">The rows to save.</param>
/// <param name="rejection">The row-level rejection, when this returns false.</param>
/// <returns><see langword="true"/> when saved.</returns>
private bool TrySave(IReadOnlyList<AuditEvent> events, out DbUpdateException? rejection)
{
using var db = _dbFactory.CreateDbContext();
foreach (var evt in events) db.ConfigAuditLogs.Add(ToRow(evt));
try
{
db.SaveChanges();
rejection = null;
return true;
}
catch (DbUpdateException ex) when (ex.Entries.Count > 0 || IsUniqueViolation(ex))
{
rejection = ex;
return false;
}
}
/// <summary>SQL Server 2601 (unique index) / 2627 (unique constraint), the dedup contract's expected rejection.</summary>
/// <param name="ex">The update failure.</param>
/// <returns><see langword="true"/> for a duplicate-key rejection.</returns>
private static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException is Microsoft.Data.SqlClient.SqlException { Number: 2601 or 2627 };
/// <inheritdoc /> /// <inheritdoc />
protected override void PreRestart(Exception reason, object message) protected override void PreRestart(Exception reason, object message)
{ {
@@ -201,6 +201,9 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
LoadContactsFromDb(); LoadContactsFromDb();
}); });
Receive<ContactsLoaded>(HandleContactsLoaded); Receive<ContactsLoaded>(HandleContactsLoaded);
Receive<PskRefreshed>(msg => _log.Debug("Mesh PSK refresh completed for {Count} cluster(s)", msg.ClusterCount));
Receive<PskRefreshFailed>(msg => _log.Warning(msg.Error,
"Mesh PSK refresh failed; previously resolved keys stay in service until the next refresh"));
Receive<CommandDispatched>(HandleCommandDispatched); Receive<CommandDispatched>(HandleCommandDispatched);
// A faulted LoadContactsFromDb task is piped here as a Status.Failure. Without this handler // A faulted LoadContactsFromDb task is piped here as a Status.Failure. Without this handler
@@ -710,6 +713,20 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
_targets = msg.Targets; _targets = msg.Targets;
// R03:1.3 — release the channels of endpoints that left the dispatch set (host renamed, port
// moved). Only the resolved dialer is asked; nothing is resolved just to prune it.
if (_dialer is { } dialer)
{
try
{
dialer.RetainOnly(_targets.Select(t => t.Endpoint).ToHashSet(StringComparer.Ordinal));
}
catch (Exception ex)
{
_log.Warning(ex, "Mesh dialer failed to release channels for retired endpoints; they stay cached until the next refresh");
}
}
if (_targets.Count == 0) if (_targets.Count == 0)
{ {
_log.Warning( _log.Warning(
@@ -780,7 +797,37 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
} }
pskProvider.SetKnownClusters(msg.ClusterIds); pskProvider.SetKnownClusters(msg.ClusterIds);
pskProvider.InvalidateAll();
// R03:3.4 — refresh the per-cluster keys OFF the request path and swap them in atomically,
// instead of dropping the cache and making the first ack per cluster re-read the store on a
// gRPC thread. A provider that does not pre-resolve (the interface default, and every fake)
// still drops its cache here, synchronously, exactly as before.
CancellationToken ct;
try
{
ct = _lifecycle.Token;
}
catch (ObjectDisposedException)
{
return; // Stopping.
}
var self = Self;
Task refresh;
try
{
refresh = pskProvider.RefreshAsync(msg.ClusterIds, ct);
}
catch (Exception ex)
{
_log.Warning(ex, "Mesh PSK refresh threw synchronously; cached keys are unchanged until the next refresh");
refresh = Task.CompletedTask;
}
if (!refresh.IsCompletedSuccessfully)
{
refresh.PipeTo(self,
success: () => new PskRefreshed(msg.ClusterIds.Count),
failure: ex => new PskRefreshFailed(ex));
}
if (!_gatePublished) if (!_gatePublished)
{ {
@@ -795,6 +842,14 @@ public sealed class CentralCommunicationActor : ReceiveActor, IWithTimers
/// <summary>Internal tick asking for a contact-point refresh.</summary> /// <summary>Internal tick asking for a contact-point refresh.</summary>
public sealed record RefreshContacts; public sealed record RefreshContacts;
/// <summary>The asynchronous PSK refresh (R03:3.4) completed.</summary>
/// <param name="ClusterCount">How many clusters were refreshed.</param>
public sealed record PskRefreshed(int ClusterCount);
/// <summary>The asynchronous PSK refresh (R03:3.4) threw; the previous keys stay in service.</summary>
/// <param name="Error">The failure.</param>
public sealed record PskRefreshFailed(Exception Error);
/// <summary>Result of a contact-point load, piped back from the DB read.</summary> /// <summary>Result of a contact-point load, piped back from the DB read.</summary>
/// <param name="Targets"> /// <param name="Targets">
/// The gRPC dispatch set: one entry per enabled, non-maintenance row that carries a /// The gRPC dispatch set: one entry per enabled, non-maintenance row that carries a
@@ -106,6 +106,27 @@ public interface IMeshPskProvider
/// <summary>Drops every cached key.</summary> /// <summary>Drops every cached key.</summary>
void InvalidateAll(); void InvalidateAll();
/// <summary>
/// Re-reads the keys for <paramref name="clusterIds"/> from the store OFF the request path and
/// swaps them into the cache atomically (R03:3.4). Until the refresh completes, lookups keep
/// serving the previous key; a cluster whose read FAILS keeps its previous key too, while a cluster whose
/// secret the store reports ABSENT is revoked (no key from then on). Replaces
/// "invalidate on every refresh", which made the first inbound ack per cluster per
/// <c>ContactRefreshSeconds</c> block a gRPC thread on a synchronous, 3 s-bounded store read.
/// </summary>
/// <param name="clusterIds">The clusters central currently knows (the same set given to <see cref="SetKnownClusters"/>).</param>
/// <param name="ct">Cancels the refresh.</param>
/// <returns>Completes when the swap has happened.</returns>
/// <remarks>
/// The default implementation is the pre-R03:3.4 behaviour — drop the cache and let the next lookup
/// re-read — so a fake or a non-caching provider needs nothing more.
/// </remarks>
Task RefreshAsync(IReadOnlyCollection<string> clusterIds, CancellationToken ct)
{
InvalidateAll();
return Task.CompletedTask;
}
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -144,6 +165,16 @@ public sealed class MeshPskProvider : IMeshPskProvider
private readonly ConcurrentDictionary<string, Lazy<string?>> _cache = private readonly ConcurrentDictionary<string, Lazy<string?>> _cache =
new(StringComparer.OrdinalIgnoreCase); new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Keys pre-resolved by <see cref="RefreshAsync"/>, consulted after the config map and before the
/// lazy <see cref="_cache"/>. Swapped wholesale (volatile reference to an immutable dictionary),
/// never mutated, so a lookup racing a refresh sees the whole previous snapshot or the whole next
/// one. A cluster missing from it — never refreshed, or whose read failed — falls through to the
/// lazy path exactly as before, which is what keeps "refresh failed" from meaning "no key".
/// </summary>
private volatile IReadOnlyDictionary<string, string> _refreshed =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary> /// <summary>
/// The known-cluster gate's membership set, or <see langword="null"/> when the gate is /// The known-cluster gate's membership set, or <see langword="null"/> when the gate is
/// inactive (never published, or published empty — see <see cref="SetKnownClusters"/>). /// inactive (never published, or published empty — see <see cref="SetKnownClusters"/>).
@@ -210,6 +241,13 @@ public sealed class MeshPskProvider : IMeshPskProvider
return true; return true;
} }
// Pre-resolved by the asynchronous refresh: no store read on this (gRPC request) thread at all.
if (_refreshed.TryGetValue(clusterId, out var refreshed))
{
psk = refreshed;
return true;
}
var entry = _cache.GetOrAdd( var entry = _cache.GetOrAdd(
clusterId, clusterId,
static (id, self) => new Lazy<string?>( static (id, self) => new Lazy<string?>(
@@ -288,7 +326,124 @@ public sealed class MeshPskProvider : IMeshPskProvider
} }
/// <inheritdoc /> /// <inheritdoc />
public void InvalidateAll() => _cache.Clear(); public void InvalidateAll()
{
_cache.Clear();
_refreshed = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Ceiling on concurrent store reads during one <see cref="RefreshAsync"/>. A fleet of N clusters used to
/// fan out N simultaneous reads every contact refresh; the store (and anything behind it) sees at most
/// this many at once now.
/// </summary>
public const int MaxConcurrentRefreshReads = 8;
/// <inheritdoc />
public async Task RefreshAsync(IReadOnlyCollection<string> clusterIds, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(clusterIds);
var previous = _refreshed;
var ids = clusterIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var results = new ConcurrentDictionary<string, StoreRead>(StringComparer.OrdinalIgnoreCase);
// Bounded fan-out; each read bounded like the synchronous path.
await Parallel.ForEachAsync(
ids,
new ParallelOptions { MaxDegreeOfParallelism = MaxConcurrentRefreshReads, CancellationToken = ct },
async (id, token) => results[id] = await ResolveFromStoreAsync(id, token).ConfigureAwait(false))
.ConfigureAwait(false);
// Three outcomes per cluster (fix round 1 / I8):
// - Found → the new key;
// - NotFound → the store answered that the secret does not exist: a REVOCATION — the cluster gets no
// key (and its lazily cached one is evicted below), so a deleted secret stops working
// within one refresh instead of living on for the life of the process;
// - Failed → the store could not be read (threw / timed out / unnameable): keep the PREVIOUS key —
// a transient fault must not open a window in which the cluster has no key at all.
var next = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var answered = new List<string>();
foreach (var id in ids)
{
var read = results[id];
switch (read.Outcome)
{
case StoreReadOutcome.Found:
next[id] = read.Value!;
answered.Add(id);
break;
case StoreReadOutcome.NotFound:
answered.Add(id);
break;
default:
if (previous.TryGetValue(id, out var kept)) next[id] = kept;
break;
}
}
// The swap. Then drop the lazy entries for every cluster the store ANSWERED for — found (serve the new
// snapshot, not a stale lazy resolution) or not found (revoked: a lazily cached key must die too).
// Entries for clusters whose read failed are left alone.
_refreshed = next;
foreach (var id in answered) _cache.TryRemove(id, out _);
}
private enum StoreReadOutcome { Found, NotFound, Failed }
private readonly record struct StoreRead(StoreReadOutcome Outcome, string? Value);
/// <summary>
/// The asynchronous twin of <see cref="ResolveFromStore"/>: same name derivation, same bound — awaited
/// instead of blocked on, because this one runs on a refresh rather than inside a gRPC interceptor —
/// but it keeps "the secret does not exist" (a blank answer) apart from "the store could not be read".
/// </summary>
/// <param name="clusterId">The application <c>Cluster</c> id.</param>
/// <param name="ct">The caller's token, linked under the store-read bound.</param>
/// <returns>The outcome and, when found, the key.</returns>
private async Task<StoreRead> ResolveFromStoreAsync(string clusterId, CancellationToken ct)
{
SecretName name;
try
{
name = new SecretName(SecretNamePrefix + clusterId);
}
catch (ArgumentException ex)
{
_logger.LogWarning(
ex,
"Cluster {ClusterId} cannot be expressed as a secret name, so its mesh PSK cannot be "
+ "refreshed from the store. Supply it through MeshTransport:ClusterPsks instead, or "
+ "rename the cluster to [a-z0-9._/-].",
clusterId);
return new StoreRead(StoreReadOutcome.Failed, null);
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(StoreReadTimeout);
try
{
var value = await _secretResolver.GetAsync(name, cts.Token).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(value)) return new StoreRead(StoreReadOutcome.Found, value);
_logger.LogWarning(
"Mesh PSK secret {SecretName} for cluster {ClusterId} is absent (or blank) in the store; the key is "
+ "REVOKED — commands to that cluster and acks from it are refused until it is restored.",
name.Value,
clusterId);
return new StoreRead(StoreReadOutcome.NotFound, null);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Failed to refresh the mesh PSK for cluster {ClusterId} from secret {SecretName}; the "
+ "previously resolved key (if any) stays in service until the next refresh.",
clusterId,
name.Value);
return new StoreRead(StoreReadOutcome.Failed, null);
}
}
/// <summary> /// <summary>
/// Reads <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store, converting every failure mode /// Reads <c>OT-MESH-PSK-{clusterId}</c> from the Secrets store, converting every failure mode
@@ -34,6 +34,15 @@ public interface IMeshCommandDialer
/// <see cref="StatusCode.Unauthenticated"/> rather than dialled unauthenticated. /// <see cref="StatusCode.Unauthenticated"/> rather than dialled unauthenticated.
/// </exception> /// </exception>
Task DispatchAsync(string nodeEndpoint, CommandEnvelope envelope, string clusterId, CancellationToken ct); Task DispatchAsync(string nodeEndpoint, CommandEnvelope envelope, string clusterId, CancellationToken ct);
/// <summary>
/// Releases any per-endpoint resources for endpoints NOT in <paramref name="nodeEndpoints"/>
/// (R03:1.3). Central calls it on every contact refresh with the current dispatch set, so a
/// renamed host or moved port does not leave the old channel and its idle HTTP/2 connection
/// alive for the life of the process. Default no-op for dialers that cache nothing.
/// </summary>
/// <param name="nodeEndpoints">The endpoints still in the dispatch set.</param>
void RetainOnly(IReadOnlyCollection<string> nodeEndpoints) { }
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -181,6 +190,30 @@ public sealed class MeshGrpcCommandDialer : IMeshCommandDialer, IDisposable
await call.ResponseAsync.ConfigureAwait(false); await call.ResponseAsync.ConfigureAwait(false);
} }
/// <inheritdoc />
public void RetainOnly(IReadOnlyCollection<string> nodeEndpoints)
{
ArgumentNullException.ThrowIfNull(nodeEndpoints);
if (_disposed) return;
var keep = nodeEndpoints as IReadOnlySet<string> ?? nodeEndpoints.ToHashSet(StringComparer.Ordinal);
foreach (var endpoint in _channels.Keys)
{
if (keep.Contains(endpoint)) continue;
// A dial that already took this channel out of the cache before the removal fails once, as
// a named per-node RpcException the actor logs; the next dial to that endpoint (if it ever
// returns to the set) builds a fresh channel. Bounded to the dials in flight at the instant
// of a contact refresh, the same accepted race Dispose documents.
if (_channels.TryRemove(endpoint, out var channel))
{
channel.Dispose();
_log.LogInformation(
"Mesh dialer released the cached channel for {Endpoint}: it is no longer in the dispatch set",
endpoint);
}
}
}
/// <summary>Marks the dialer disposed and disposes every cached gRPC channel.</summary> /// <summary>Marks the dialer disposed and disposes every cached gRPC channel.</summary>
public void Dispose() public void Dispose()
{ {
@@ -161,9 +161,12 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentAcksTopic, Self)); DistributedPubSub.Get(Context.System).Mediator.Tell(new Subscribe(DeploymentAcksTopic, Self));
using var db = _dbFactory.CreateDbContext(); using var db = _dbFactory.CreateDbContext();
// Projected: the row carries the whole ArtifactBlob (varbinary(max)), which this recovery never
// reads — and this query runs on every singleton start and hand-over.
var inflight = db.Deployments var inflight = db.Deployments
.Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks) .Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks)
.OrderByDescending(d => d.CreatedAtUtc) .OrderByDescending(d => d.CreatedAtUtc)
.Select(d => new { d.DeploymentId, d.RevisionHash, d.CreatedAtUtc })
.FirstOrDefault(); .FirstOrDefault();
if (inflight is null) return; if (inflight is null) return;
@@ -173,6 +176,41 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
.AsNoTracking() .AsNoTracking()
.ToList(); .ToList();
// Resume the deadline timer using the remaining time. The deadline runs from when the
// deployment was first marked AwaitingApplyAcks (Deployment.CreatedAtUtc is a close enough
// proxy — we don't track a separate "dispatched at" column).
var elapsed = DateTime.UtcNow - inflight.CreatedAtUtc;
var remaining = _applyDeadline - elapsed;
if (nodeStates.Count == 0)
{
// The expected set and the AwaitingApplyAcks flip are written in ONE SaveChanges by
// HandleDispatch, so a row with no NodeDeploymentState rows is a dispatch that never completed:
// HandleDispatch threw before its SaveChanges (this actor restarted and the message was
// dropped), the process died between AdminOps' row write and the coordinator's Tell, or the
// Tell was lost across a singleton hand-over. Recovering it with an EMPTY expected set used to
// let the deadline take the "everyone acked" race branch (0 == 0) and leave the row in flight
// forever, refusing every later StartDeployment. Inside the deadline the dispatch is simply
// re-driven through the normal handler (bounded: CreatedAtUtc never moves, so a dispatch that
// keeps throwing ends up on the other branch once the deadline passes); past it the row is
// closed as TimedOut with a reason that names the condition.
if (remaining > TimeSpan.Zero)
{
_log.Warning(
"Coordinator recovered deployment {Id} with NO expected-ack rows — the dispatch was lost " +
"before any node was expected (coordinator restart or singleton hand-over mid-dispatch); " +
"re-dispatching with {Remaining} of the apply deadline left",
_current, remaining);
Self.Tell(new DispatchDeployment(
_current.Value, RevisionHash.Parse(inflight.RevisionHash), CorrelationId.NewId()));
}
else
{
Self.Tell(new DeadlineElapsed(_current.Value));
}
return;
}
_expectedAcks = nodeStates.Select(s => NodeId.Parse(s.NodeId)).ToHashSet(NodeIdComparer); _expectedAcks = nodeStates.Select(s => NodeId.Parse(s.NodeId)).ToHashSet(NodeIdComparer);
foreach (var s in nodeStates.Where(s => s.Status != NodeDeploymentStatus.Applying)) foreach (var s in nodeStates.Where(s => s.Status != NodeDeploymentStatus.Applying))
{ {
@@ -182,11 +220,6 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
if (IsDegraded(s.Status, s.FailureReason)) _degraded.Add(NodeId.Parse(s.NodeId)); if (IsDegraded(s.Status, s.FailureReason)) _degraded.Add(NodeId.Parse(s.NodeId));
} }
// Resume the deadline timer using the remaining time. The deadline runs from when the
// deployment was first marked AwaitingApplyAcks (Deployment.CreatedAtUtc is a close enough
// proxy — we don't track a separate "dispatched at" column).
var elapsed = DateTime.UtcNow - inflight.CreatedAtUtc;
var remaining = _applyDeadline - elapsed;
if (remaining <= TimeSpan.Zero) if (remaining <= TimeSpan.Zero)
{ {
Self.Tell(new DeadlineElapsed(_current.Value)); Self.Tell(new DeadlineElapsed(_current.Value));
@@ -201,6 +234,33 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
private void HandleDispatch(DispatchDeployment msg) private void HandleDispatch(DispatchDeployment msg)
{ {
// Terminal-state guard (fix round 1): a late or re-delivered dispatch for a row that is already
// Sealed / PartiallyFailed / TimedOut — e.g. one AdminOps closed as a lost dispatch while this message
// was still queued — must never flip it back to AwaitingApplyAcks or seed an expected set for it.
//
// The RowVersion read HERE is the one the AwaitingApplyAcks flip below is conditioned on (re-review 1):
// reading a fresh one at flip time left a window in which AdminOps' lost-dispatch closure could commit
// TimedOut between the guard and the flip, and the flip would silently reopen the row. With the guard's
// RowVersion, any change since the guard read makes the flip a concurrency conflict instead.
byte[]? guardRowVersion;
using (var check = _dbFactory.CreateDbContext())
{
var guard = check.Deployments
.AsNoTracking()
.Where(d => d.DeploymentId == msg.DeploymentId.Value)
.Select(d => new { d.Status, d.RowVersion })
.FirstOrDefault();
if (guard?.Status is DeploymentStatus.Sealed or DeploymentStatus.PartiallyFailed or DeploymentStatus.TimedOut)
{
_log.Warning(
"Ignoring DispatchDeployment {Id}: the deployment is already {Status} (a late or duplicated dispatch)",
msg.DeploymentId, guard.Status);
if (_current == msg.DeploymentId) ResetForNext();
return;
}
guardRowVersion = guard?.RowVersion;
}
_current = msg.DeploymentId; _current = msg.DeploymentId;
_acks.Clear(); _acks.Clear();
_degraded.Clear(); _degraded.Clear();
@@ -208,22 +268,51 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
// Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack. // Seed NodeDeploymentState rows so a failover knows which nodes were expected to ack.
using (var db = _dbFactory.CreateDbContext()) using (var db = _dbFactory.CreateDbContext())
{ {
// Discovery reuses this context rather than opening a second one — it reads the same // Idempotent on a re-dispatch (PreStart re-drives a lost dispatch through this handler): rows
// ClusterNode table the NodeDeploymentState rows below are FK-bound to, so a single // already seeded for this deployment ARE the expected set — re-discovering would both duplicate
// context keeps the expected set and the seeded rows consistent by construction. // the primary key and let a node toggled since the first attempt change who is expected.
_expectedAcks = DiscoverDriverNodes(db); var seeded = db.NodeDeploymentStates
.AsNoTracking()
foreach (var node in _expectedAcks) .Where(x => x.DeploymentId == msg.DeploymentId.Value)
.Select(x => x.NodeId)
.ToList();
if (seeded.Count > 0)
{ {
db.NodeDeploymentStates.Add(new NodeDeploymentState _expectedAcks = seeded.Select(NodeId.Parse).ToHashSet(NodeIdComparer);
{ }
NodeId = node.Value, else
DeploymentId = msg.DeploymentId.Value, {
Status = NodeDeploymentStatus.Applying, // Discovery reuses this context rather than opening a second one — it reads the same
}); // ClusterNode table the NodeDeploymentState rows below are FK-bound to, so a single
// context keeps the expected set and the seeded rows consistent by construction.
_expectedAcks = DiscoverDriverNodes(db);
foreach (var node in _expectedAcks)
{
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = node.Value,
DeploymentId = msg.DeploymentId.Value,
Status = NodeDeploymentStatus.Applying,
});
}
}
UpdateDeploymentStatus(db, msg.DeploymentId, DeploymentStatus.AwaitingApplyAcks, rowVersion: guardRowVersion);
try
{
db.SaveChanges();
}
catch (DbUpdateConcurrencyException)
{
// The row moved after the guard read (on SQL Server: AdminOps closed it as a lost dispatch). The
// expected-set rows and the flip were one SaveChanges, so nothing was written; nothing is
// published and the coordinator is free for the next deployment.
_log.Warning(
"DispatchDeployment {Id} abandoned: the deployment changed state while it was being dispatched " +
"(closed as a lost dispatch?) — not reopening it", msg.DeploymentId);
ResetForNext();
return;
} }
UpdateDeploymentStatus(db, msg.DeploymentId, DeploymentStatus.AwaitingApplyAcks);
db.SaveChanges();
} }
// Per-cluster mesh Phase 2: hand off to the transport router rather than publishing // Per-cluster mesh Phase 2: hand off to the transport router rather than publishing
@@ -323,11 +412,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
// same as clean, though: the deployment's reason names the degraded nodes (per-node detail is on // same as clean, though: the deployment's reason names the degraded nodes (per-node detail is on
// each NodeDeploymentState row) and the seal logs at Warning. // each NodeDeploymentState row) and the seal logs at Warning.
var nodes = _degraded.Select(n => n.Value).Order(StringComparer.OrdinalIgnoreCase).ToList(); var nodes = _degraded.Select(n => n.Value).Order(StringComparer.OrdinalIgnoreCase).ToList();
var d = db.Deployments.FirstOrDefault(x => x.DeploymentId == _current.Value.Value); SetFailureReason(db, Truncate(
if (d is not null) $"Sealed with a degraded address-space apply on {nodes.Count} node(s): {string.Join(", ", nodes)}. " +
d.FailureReason = Truncate( "See each node's NodeDeploymentState.FailureReason; re-deploying the same revision re-applies it."));
$"Sealed with a degraded address-space apply on {nodes.Count} node(s): {string.Join(", ", nodes)}. " +
"See each node's NodeDeploymentState.FailureReason; re-deploying the same revision re-applies it.");
db.SaveChanges(); db.SaveChanges();
_log.Warning( _log.Warning(
"Deployment {Id} sealed DEGRADED (acks={Count}) — the address-space apply was degraded on: {Nodes}", "Deployment {Id} sealed DEGRADED (acks={Count}) — the address-space apply was degraded on: {Nodes}",
@@ -360,7 +447,9 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
msg.DeploymentId, _current); msg.DeploymentId, _current);
return; return;
} }
if (_acks.Count == _expectedAcks.Count) // Only a NON-EMPTY expected set can be "fully acked": with nothing expected, 0 == 0 is not the
// everyone-acked race, it is a lost dispatch (see PreStart) that must be closed, not skipped.
if (_expectedAcks.Count > 0 && _acks.Count == _expectedAcks.Count)
{ {
// Race: every node acked just as the deadline fired. Already sealed/failed elsewhere. // Race: every node acked just as the deadline fired. Already sealed/failed elsewhere.
return; return;
@@ -368,6 +457,19 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
using var db = _dbFactory.CreateDbContext(); using var db = _dbFactory.CreateDbContext();
UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.TimedOut); UpdateDeploymentStatus(db, _current.Value, DeploymentStatus.TimedOut);
if (_expectedAcks.Count == 0)
{
SetFailureReason(db, Truncate(
"Dispatch was lost before any node was expected to ack (coordinator restart or singleton " +
"hand-over mid-dispatch) and the apply deadline has passed. Redeploy."));
db.SaveChanges();
_log.Warning(
"Deployment {Id} timed out with NO expected-ack rows: the dispatch was lost before any node was " +
"expected and the {Deadline} apply deadline has passed. Closed as TimedOut — redeploy",
_current.Value, _applyDeadline);
ResetForNext();
return;
}
db.SaveChanges(); db.SaveChanges();
// Name the silent nodes, not just the counts. Since the expected set became DB-derived this // Name the silent nodes, not just the counts. Since the expected set became DB-derived this
// is the failure an operator meets after deploying while a configured node is switched off, // is the failure an operator meets after deploying while a configured node is switched off,
@@ -394,13 +496,63 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers
public sealed record DeadlineElapsed(DeploymentId DeploymentId); public sealed record DeadlineElapsed(DeploymentId DeploymentId);
/// <summary>
/// Stages a status change WITHOUT materialising the row: the row carries the whole <c>ArtifactBlob</c>
/// (varbinary(max), multi-MB on a real fleet), which no status transition reads (fix round 1). A stub
/// carrying only the key and the row's current RowVersion is attached and the changed columns marked
/// modified, so the UPDATE writes those columns and keeps the optimistic-concurrency check. The staged
/// stub stays tracked so <see cref="SetFailureReason"/> can add to the same UPDATE.
/// </summary>
private static void UpdateDeploymentStatus( private static void UpdateDeploymentStatus(
OtOpcUaConfigDbContext db, DeploymentId id, DeploymentStatus status, bool sealNow = false) OtOpcUaConfigDbContext db, DeploymentId id, DeploymentStatus status, bool sealNow = false, byte[]? rowVersion = null)
{ {
var d = db.Deployments.FirstOrDefault(x => x.DeploymentId == id.Value); var entry = TrackStub(db, id, rowVersion);
if (d is null) return; if (entry is null) return;
d.Status = status; entry.Entity.Status = status;
if (sealNow) d.SealedAtUtc = DateTime.UtcNow; entry.Property(x => x.Status).IsModified = true;
if (sealNow)
{
entry.Entity.SealedAtUtc = DateTime.UtcNow;
entry.Property(x => x.SealedAtUtc).IsModified = true;
}
}
/// <summary>Stages the current deployment's failure reason onto the stub <see cref="UpdateDeploymentStatus"/> staged.</summary>
private void SetFailureReason(OtOpcUaConfigDbContext db, string reason)
{
if (_current is null) return;
var entry = TrackStub(db, _current.Value);
if (entry is null) return;
entry.Entity.FailureReason = reason;
entry.Property(x => x.FailureReason).IsModified = true;
}
/// <summary>The tracked blob-free stub for a deployment, attaching one on first use; null when the row is gone.</summary>
/// <param name="db">The context.</param>
/// <param name="id">The deployment.</param>
/// <param name="rowVersion">
/// The RowVersion the update must be conditioned on, when the caller already read one (the dispatch
/// guard); <see langword="null"/> reads the current one.
/// </param>
private static Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Deployment>? TrackStub(
OtOpcUaConfigDbContext db, DeploymentId id, byte[]? rowVersion = null)
{
var tracked = db.ChangeTracker.Entries<Deployment>().FirstOrDefault(e => e.Entity.DeploymentId == id.Value);
if (tracked is not null) return tracked;
if (rowVersion is null)
{
var stamp = db.Deployments
.AsNoTracking()
.Where(x => x.DeploymentId == id.Value)
.Select(x => new { x.RowVersion })
.FirstOrDefault();
if (stamp is null) return null;
rowVersion = stamp.RowVersion;
}
return db.Deployments.Attach(new Deployment
{
DeploymentId = id.Value, RevisionHash = string.Empty, CreatedBy = string.Empty, RowVersion = rowVersion,
});
} }
/// <summary> /// <summary>
@@ -0,0 +1,190 @@
using Akka.Actor;
using Akka.Event;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
/// <summary>
/// Admin-role cluster singleton that periodically empties the <c>ArtifactBlob</c> of old, terminal
/// deployments (R03:1.2). See <see cref="DeploymentRetentionOptions"/> for the policy and
/// <see cref="SweepAsync"/> for the rule, which is pure over a context so it is unit-testable.
/// </summary>
/// <remarks>
/// <para>
/// <b>Empties, never deletes, and never nulls.</b> The column is <c>NOT NULL varbinary(max)</c>, and
/// every reader (<c>DriverHostActor.LoadArtifactBlob</c>, <c>OpcUaPublishActor</c>,
/// <c>DeploymentArtifactService</c>) already treats a zero-length blob as "no artifact" (#485: empty
/// bytes are not an empty configuration — the apply fails and the node keeps last-known-good). An
/// empty blob therefore needs no schema change and no reader change, where a NULL would need both.
/// </para>
/// <para>
/// <b>Never touches an in-flight row</b> (Dispatching / AwaitingApplyAcks): a node may still be
/// fetching it. The newest <see cref="DeploymentRetentionOptions.KeepSealed"/> terminal rows keep
/// their blob whatever their age, so the "latest sealed" every boot path reads is always intact.
/// </para>
/// </remarks>
public sealed class DeploymentRetentionActor : ReceiveActor, IWithTimers
{
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly DeploymentRetentionOptions _options;
private readonly ILoggingAdapter _log = Context.GetLogger();
private const string SweepTimerKey = "retention-sweep";
/// <summary>Gets or sets the timer scheduler.</summary>
public ITimerScheduler Timers { get; set; } = null!;
/// <summary>Creates the actor props.</summary>
/// <param name="dbFactory">The config DB factory.</param>
/// <param name="options">The retention policy.</param>
/// <returns>The props.</returns>
public static Props Props(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentRetentionOptions options) =>
Akka.Actor.Props.Create(() => new DeploymentRetentionActor(dbFactory, options));
/// <summary>Initializes a new instance of the <see cref="DeploymentRetentionActor"/> class.</summary>
/// <param name="dbFactory">The config DB factory.</param>
/// <param name="options">The retention policy.</param>
public DeploymentRetentionActor(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentRetentionOptions options)
{
ArgumentNullException.ThrowIfNull(dbFactory);
ArgumentNullException.ThrowIfNull(options);
_dbFactory = dbFactory;
_options = options;
Receive<Sweep>(_ => RunSweep());
Receive<SweepCompleted>(msg =>
{
if (msg.Pruned > 0)
_log.Info("Deployment retention emptied the artifact blob on {Count} old deployment(s)", msg.Pruned);
else
_log.Debug("Deployment retention sweep found nothing to prune");
if (msg.ReplyTo is not null) msg.ReplyTo.Tell(msg);
});
Receive<SweepFailed>(msg =>
{
_log.Warning(msg.Error, "Deployment retention sweep failed; the next sweep retries");
if (msg.ReplyTo is not null) msg.ReplyTo.Tell(msg);
});
}
/// <inheritdoc />
protected override void PreStart()
{
if (!_options.Enabled)
{
_log.Info("Deployment retention is disabled (Deployments:Retention:Enabled=false); artifact blobs are kept forever");
return;
}
var interval = TimeSpan.FromMinutes(Math.Max(1, _options.IntervalMinutes));
var initial = TimeSpan.FromSeconds(Math.Max(0, _options.InitialDelaySeconds));
Timers.StartPeriodicTimer(SweepTimerKey, Sweep.Instance, initial, interval);
}
private void RunSweep()
{
var replyTo = Sender.IsNobody() ? null : Sender;
var self = Self;
var now = DateTime.UtcNow;
Task.Run(async () =>
{
await using var db = await _dbFactory.CreateDbContextAsync().ConfigureAwait(false);
return await SweepAsync(db, _options, now).ConfigureAwait(false);
})
.PipeTo(self,
success: pruned => new SweepCompleted(pruned, replyTo),
failure: ex => new SweepFailed(ex, replyTo));
}
/// <summary>
/// The retention rule. Empties the blob of every terminal deployment that is (a) not one of the
/// newest <see cref="DeploymentRetentionOptions.KeepSealed"/> SEALED rows (at least one — the newest
/// Sealed blob is never pruned), (b) not referenced by a
/// <c>NodeDeploymentState</c> row started or applied within
/// <see cref="DeploymentRetentionOptions.ReferencedDays"/>, and (c) still carrying bytes — at most
/// <see cref="DeploymentRetentionOptions.MaxRowsPerSweep"/> per call.
/// </summary>
/// <param name="db">The config DB.</param>
/// <param name="options">The policy.</param>
/// <param name="utcNow">The sweep's clock.</param>
/// <param name="ct">Cancellation.</param>
/// <returns>How many rows were emptied.</returns>
public static async Task<int> SweepAsync(
OtOpcUaConfigDbContext db, DeploymentRetentionOptions options, DateTime utcNow, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(db);
ArgumentNullException.ThrowIfNull(options);
var keep = Math.Max(1, options.KeepSealed);
var referenceFloor = utcNow.AddDays(-Math.Max(0, options.ReferencedDays));
var batch = Math.Max(1, options.MaxRowsPerSweep);
// Projected: ids only. The candidate scan must not materialise blobs for rows it will keep.
var terminal = await db.Deployments
.AsNoTracking()
.Where(d => d.Status == DeploymentStatus.Sealed
|| d.Status == DeploymentStatus.PartiallyFailed
|| d.Status == DeploymentStatus.TimedOut)
.OrderByDescending(d => d.CreatedAtUtc)
.Select(d => new { d.DeploymentId, d.Status, HasBlob = d.ArtifactBlob.Length > 0 })
.ToListAsync(ct)
.ConfigureAwait(false);
// The keep window counts SEALED rows only (fix round 1 / I3). Counting every terminal row let a burst
// of failed deploys (TimedOut / PartiallyFailed) fill the window by itself and push the fleet's last
// good artifact — the newest Sealed row, which boot, drift detection and the publish fallback all
// read — out of it. `keep` is clamped to ≥ 1, so the newest Sealed row is never a candidate.
var keptSealed = terminal
.Where(d => d.Status == DeploymentStatus.Sealed)
.Take(keep)
.Select(d => d.DeploymentId)
.ToHashSet();
var aged = terminal
.Where(d => d.HasBlob && !keptSealed.Contains(d.DeploymentId))
.Select(d => d.DeploymentId)
.ToList();
if (aged.Count == 0) return 0;
var referenced = (await db.NodeDeploymentStates
.AsNoTracking()
.Where(s => aged.Contains(s.DeploymentId)
&& (s.StartedAtUtc >= referenceFloor
|| (s.AppliedAtUtc != null && s.AppliedAtUtc >= referenceFloor)))
.Select(s => s.DeploymentId)
.Distinct()
.ToListAsync(ct)
.ConfigureAwait(false))
.ToHashSet();
var targets = aged.Where(id => !referenced.Contains(id)).Take(batch).ToList();
if (targets.Count == 0) return 0;
// Tracked load of ONLY the rows being emptied: the update rides the row's real RowVersion on
// both providers, and the blob read is the one-time cost of retiring that row.
var rows = await db.Deployments
.Where(d => targets.Contains(d.DeploymentId))
.ToListAsync(ct)
.ConfigureAwait(false);
foreach (var row in rows) row.ArtifactBlob = [];
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return rows.Count;
}
/// <summary>Asks for a sweep now; the sender (if any) receives the <see cref="SweepCompleted"/> / <see cref="SweepFailed"/>.</summary>
public sealed class Sweep
{
/// <summary>The singleton instance.</summary>
public static readonly Sweep Instance = new();
private Sweep() { }
}
/// <summary>A sweep finished.</summary>
/// <param name="Pruned">How many rows were emptied.</param>
/// <param name="ReplyTo">Who asked, when the sweep was requested rather than timer-driven.</param>
public sealed record SweepCompleted(int Pruned, IActorRef? ReplyTo);
/// <summary>A sweep threw.</summary>
/// <param name="Error">The failure.</param>
/// <param name="ReplyTo">Who asked, when the sweep was requested rather than timer-driven.</param>
public sealed record SweepFailed(Exception Error, IActorRef? ReplyTo);
}
@@ -0,0 +1,55 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
/// <summary>
/// <c>Deployments:Retention</c> — how long a sealed deployment keeps its <c>ArtifactBlob</c> (R03:1.2).
/// Every deploy stored the whole gzip'd fleet snapshot forever; nothing pruned it, so a year of routine
/// edits was gigabytes of blobs. The retention sweep EMPTIES the blob on old rows and never deletes a
/// row: the audit record (who deployed what revision, when, and how every node acked) survives.
/// </summary>
public sealed class DeploymentRetentionOptions
{
/// <summary>The configuration section name.</summary>
public const string SectionName = "Deployments:Retention";
/// <summary>Master switch; <see langword="false"/> keeps every blob forever (the previous behaviour).</summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// How many of the most recent SEALED deployments keep their blob regardless of age (clamped to ≥ 1).
/// Failed deploys (PartiallyFailed / TimedOut) do not count toward it, so a burst of them can never push
/// the last good artifact out. A node booting from central SQL reads the LATEST sealed row's blob, so
/// this must stay well above 1; 50 also keeps a comfortable re-deploy history.
/// </summary>
public int KeepSealed { get; set; } = 50;
/// <summary>
/// A deployment any <c>NodeDeploymentState</c> row started or applied within this many days keeps its
/// blob even when older than the <see cref="KeepSealed"/> window — a node that just applied it may
/// still replay it on restart.
/// </summary>
public int ReferencedDays { get; set; } = 30;
/// <summary>How often the sweep runs.</summary>
public int IntervalMinutes { get; set; } = 60;
/// <summary>Delay before the first sweep after the singleton starts, so a boot is not a blob scan.</summary>
public int InitialDelaySeconds { get; set; } = 60;
/// <summary>
/// Rows emptied per sweep. The sweep loads each candidate row (blob included) to update it under its
/// RowVersion, so this bounds one sweep's memory and transaction size; a backlog drains over sweeps.
/// </summary>
public int MaxRowsPerSweep { get; set; } = 200;
/// <summary>Binds the section from configuration, with defaults for anything absent.</summary>
/// <param name="configuration">The app configuration; null yields the defaults.</param>
/// <returns>The bound options.</returns>
public static DeploymentRetentionOptions Bind(IConfiguration? configuration)
{
var options = new DeploymentRetentionOptions();
configuration?.GetSection(SectionName).Bind(options);
return options;
}
}
@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit; using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
@@ -15,6 +16,7 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces; using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet; using ZB.MOM.WW.OtOpcUa.ControlPlane.Fleet;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane; namespace ZB.MOM.WW.OtOpcUa.ControlPlane;
@@ -30,6 +32,7 @@ public static class ServiceCollectionExtensions
public const string AuditWriterSingletonName = "audit-writer"; public const string AuditWriterSingletonName = "audit-writer";
public const string RedundancyStateSingletonName = "redundancy-state"; public const string RedundancyStateSingletonName = "redundancy-state";
public const string ClusterNodeAddressReconcilerSingletonName = "cluster-node-address-reconciler"; public const string ClusterNodeAddressReconcilerSingletonName = "cluster-node-address-reconciler";
public const string DeploymentRetentionSingletonName = "deployment-retention";
/// <summary> /// <summary>
/// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — three with /// Registers the admin-role cluster singletons on the AkkaConfigurationBuilder — three with
@@ -109,7 +112,7 @@ public static class ServiceCollectionExtensions
// coordinator. Deliberately NOT an ActorSelection.ResolveOne here: that blocks // coordinator. Deliberately NOT an ActorSelection.ResolveOne here: that blocks
// inside a props factory and races the spawn it is waiting for. // inside a props factory and races the spawn it is waiting for.
var meshRouter = registry.Get<CentralCommunicationActorKey>(); var meshRouter = registry.Get<CentralCommunicationActorKey>();
return ConfigPublishCoordinator.Props(dbFactory, applyDeadline: null, meshRouter: meshRouter); return ConfigPublishCoordinator.Props(dbFactory, applyDeadline: DeployTimings.ApplyDeadline, meshRouter: meshRouter);
}, },
singletonOptions); singletonOptions);
@@ -126,7 +129,10 @@ public static class ServiceCollectionExtensions
resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"], resolver.GetService<IConfiguration>()?["Deployment:TagConfigValidationMode"],
ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn; ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn;
var meshRouter = registry.Get<CentralCommunicationActorKey>(); var meshRouter = registry.Get<CentralCommunicationActorKey>();
return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode, meshRouter); // The SAME deadline source as the coordinator above: AdminOps closes a lost dispatch only once it
// is past the coordinator's deadline, so the two must never drift.
return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode, meshRouter,
applyDeadline: DeployTimings.ApplyDeadline);
}, },
singletonOptions); singletonOptions);
@@ -181,6 +187,21 @@ public static class ServiceCollectionExtensions
singletonOptions, singletonOptions,
createProxyToo: false); createProxyToo: false);
// Deployment artifact retention (R03:1.2): empties ArtifactBlob on old terminal deployments so the
// Deployment table stops growing by one fleet snapshot per edit. Admin singleton for the same
// reason as the reconciler (one sweeper per fleet, and only central holds the ConfigDb);
// proxy-less because nothing addresses it in production — a test sends Sweep directly.
builder.WithSingleton<DeploymentRetentionKey>(
DeploymentRetentionSingletonName,
(system, registry, resolver) =>
{
var dbFactory = resolver.GetService<IDbContextFactory<OtOpcUaConfigDbContext>>();
var options = DeploymentRetentionOptions.Bind(resolver.GetService<IConfiguration>());
return DeploymentRetentionActor.Props(dbFactory, options);
},
singletonOptions,
createProxyToo: false);
return builder; return builder;
} }
@@ -256,4 +277,5 @@ public sealed class AdminOperationsActorKey { }
public sealed class AuditWriterActorKey { } public sealed class AuditWriterActorKey { }
public sealed class RedundancyStateActorKey { } public sealed class RedundancyStateActorKey { }
public sealed class ClusterNodeAddressReconcilerKey { } public sealed class ClusterNodeAddressReconcilerKey { }
public sealed class DeploymentRetentionKey { }
public sealed class CentralCommunicationActorKey { } public sealed class CentralCommunicationActorKey { }
@@ -0,0 +1,41 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Fail-fast startup validator for <see cref="ApiKeyOptions"/> (R03:2.3). The pepper used to be
/// checked only when the first key was verified — <c>ApiKeySecret.Verify</c> throws on an empty one —
/// so a site node deployed without <c>Security__ApiKeys__Pepper</c> booted green, passed health,
/// applied deployments and denied every machine client with the same opaque "Invalid API key" until
/// someone read the log; a central node with a wrong pepper minted keys that could verify nowhere.
/// Every other security section already fails host start on a bad value; this one now does too.
/// </summary>
/// <remarks>
/// Registered for <c>hasDriver || hasAdmin</c>: a driver node needs the pepper to VERIFY keys, an admin
/// node to MINT them. The minimum length is a floor against a placeholder (<c>"x"</c>) passing as
/// configured, not a strength claim — the pepper is a server-side secret, not a password.
/// </remarks>
public sealed class ApiKeyOptionsValidator : OptionsValidatorBase<ApiKeyOptions>
{
/// <summary>The shortest pepper accepted (characters, after trimming).</summary>
public const int MinPepperLength = 16;
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, ApiKeyOptions options)
{
var pepper = options.Pepper?.Trim() ?? string.Empty;
builder.RequireThat(pepper.Length > 0,
$"{ApiKeyOptions.SectionName}:Pepper is required on any node with the driver or admin role — " +
"supply it via Security__ApiKeys__Pepper or a ${secret:} reference. An empty pepper would reject " +
"every API key (driver) or mint keys that can never verify (admin).");
builder.RequireThat(pepper.Length == 0 || pepper.Length >= MinPepperLength,
$"{ApiKeyOptions.SectionName}:Pepper must be at least {MinPepperLength} characters.");
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Username),
$"{ApiKeyOptions.SectionName}:Username must not be blank.");
builder.RequireThat(options.StoreTimeoutMs >= 0,
$"{ApiKeyOptions.SectionName}:StoreTimeoutMs must be >= 0 (0 disables the deadline).");
builder.RequireThat(options.StoreCacheSeconds >= 0,
$"{ApiKeyOptions.SectionName}:StoreCacheSeconds must be >= 0 (0 disables the cache).");
}
}
@@ -80,7 +80,10 @@ public static class DataPlaneAuthRegistration
sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)), sp.GetRequiredService<IOptions<AkkaClusterOptions>>().Value)),
sp.GetRequiredService<IOptions<ApiKeyOptions>>(), sp.GetRequiredService<IOptions<ApiKeyOptions>>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger<BoundedApiKeyStore>(),
sp.GetRequiredService<TimeProvider>())); sp.GetRequiredService<TimeProvider>(),
// R03:3.3 — the known-id set + miss budget in front of the SQL lookup; same section as
// ApiKeyOptions, Host-owned knobs.
ApiKeyStoreGuardOptions.Bind(sp.GetService<IConfiguration>())));
services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>(); services.AddSingleton<IOpcUaUserAuthenticator, ApiKeyOpcUaUserAuthenticator>();
// Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb. // Minting surface for the AdminUI. Same node condition as the store above: both need a ConfigDb.
@@ -11,6 +11,7 @@ using ZB.MOM.WW.LocalDb.Replication;
using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Configuration; using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Health; namespace ZB.MOM.WW.OtOpcUa.Host.Health;
@@ -28,11 +29,40 @@ public static class HealthEndpoints
/// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to /// factory, so probing it would throw when the readiness endpoint resolves the factory. Defaults to
/// <c>true</c> so admin/fused callers (including the test harnesses) keep the probe unchanged. /// <c>true</c> so admin/fused callers (including the test harnesses) keep the probe unchanged.
/// </param> /// </param>
/// <param name="hasDriver">
/// Whether this node carries the <c>driver</c> role and therefore serves an OPC UA endpoint. When
/// <c>true</c> the <see cref="OpcUaEndpointHealthCheck"/> joins the ready tier, and the active tier on a
/// driver-ONLY node (R03:2.1; on a fused node the active tier routes the AdminUI — see the body), reading
/// the <see cref="IOpcUaEndpointState"/> the host registers alongside <c>OtOpcUaServerHostedService</c>.
/// Defaults to <c>false</c> so admin-only callers and the existing harnesses register nothing new.
/// </param>
/// <returns>The same service collection, for chaining.</returns> /// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true) public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services, bool hasAdmin = true, bool hasDriver = false)
{ {
var checks = services.AddHealthChecks(); var checks = services.AddHealthChecks();
// A failed SDK start takes the node out of the READY tier on every driver node: it cannot serve.
// The ACTIVE tier depends on the roles (coordinator ruling, fix round 1 / I6):
// - driver-only: Active too — a site node's whole purpose is its OPC UA endpoint, so nothing may
// route to it;
// - fused admin+driver: NOT Active, until the ServiceLevel calculator consumes IOpcUaEndpointState
// (follow-up F-C1). Traefik routes the AdminUI on /health/active, so putting the probe there would
// take the operator's only console out of rotation precisely when they need it to diagnose the
// failed endpoint, while the OPC UA failover (ServiceLevel) still would not move. Once F-C1 lands
// the peer takes Primary and the active tier follows it on its own.
// Factory-registered so the state singleton is resolved per probe.
if (hasDriver)
{
var tags = hasAdmin
? new[] { ZbHealthTags.Ready }
: new[] { ZbHealthTags.Ready, ZbHealthTags.Active };
checks.Add(new HealthCheckRegistration(
OpcUaEndpointHealthCheck.Name,
sp => new OpcUaEndpointHealthCheck(sp.GetRequiredService<IOpcUaEndpointState>()),
failureStatus: null,
tags: tags));
}
// configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config // configdb probe is admin-only (per-cluster mesh Phase 4). A driver-only node gets its config
// from central via ConfigSource:Mode=FetchAndCache and never registers the ConfigDb factory, so // from central via ConfigSource:Mode=FetchAndCache and never registers the ConfigDb factory, so
// the type-activated DatabaseHealthCheck would throw resolving it. Admin + fused nodes: unchanged. // the type-activated DatabaseHealthCheck would throw resolving it. Admin + fused nodes: unchanged.
@@ -0,0 +1,47 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
/// <summary>
/// Ready-tier probe (plus Active on a driver-only node) for the node's OPC UA endpoint (R03:2.1): Healthy only while the SDK is
/// listening. A driver node whose SDK failed to start is otherwise indistinguishable from a healthy
/// one over HTTP — the configdb and akka probes are both green — while every OPC UA client gets
/// connection-refused.
/// </summary>
/// <remarks>
/// Registered on driver-role nodes only (<see cref="HealthEndpoints.AddOtOpcUaHealth"/>); an
/// admin-only node serves no OPC UA endpoint and must not be degraded by one it never starts. On a fused
/// admin+driver node it is Ready-only until ServiceLevel consumes the fact (F-C1), because the Active tier
/// is what Traefik routes the AdminUI on.
/// </remarks>
public sealed class OpcUaEndpointHealthCheck : IHealthCheck
{
/// <summary>The registration name, as it appears in the health JSON.</summary>
public const string Name = "opcua-endpoint";
private readonly IOpcUaEndpointState _state;
/// <summary>Initializes a new instance of the <see cref="OpcUaEndpointHealthCheck"/> class.</summary>
/// <param name="state">The endpoint lifecycle fact the hosted service maintains.</param>
public OpcUaEndpointHealthCheck(IOpcUaEndpointState state)
{
ArgumentNullException.ThrowIfNull(state);
_state = state;
}
/// <inheritdoc />
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object> { ["status"] = _state.Status.ToString() };
var result = _state.Status switch
{
OpcUaEndpointStatus.Started => HealthCheckResult.Healthy("OPC UA endpoint is listening", data),
OpcUaEndpointStatus.NotStarted => HealthCheckResult.Unhealthy("OPC UA endpoint has not started yet", data: data),
OpcUaEndpointStatus.Stopped => HealthCheckResult.Unhealthy("OPC UA endpoint is stopped", data: data),
_ => HealthCheckResult.Unhealthy(
"OPC UA SDK failed to start: " + (_state.FailureReason ?? "no reason recorded"), data: data),
};
return Task.FromResult(result);
}
}
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>
/// The pre-authentication guards <see cref="BoundedApiKeyStore"/> puts in front of the ConfigDb key
/// lookup (R03:3.3). Bound from the same <c>Security:ApiKeys</c> section as <c>ApiKeyOptions</c> but
/// owned by the Host, where the store lives.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why.</b> The negative cache was keyed by key id, so a flood of DISTINCT well-formed
/// <c>otua_&lt;random&gt;_x</c> credentials missed it every time and cost one indexed SQL query per
/// attempt — under the SDK's SessionManager-wide lock — with no credential needed. Two guards close
/// that: a <b>known-id set</b> (every <c>ApiKeyId</c> the ConfigDb holds, refreshed every
/// <c>StoreCacheSeconds</c>) rejects an unknown id in memory, and a <b>miss budget</b> (token
/// bucket) bounds how many lookups that reach the store and MISS are allowed per second when the
/// set is unavailable.
/// </para>
/// <para>
/// The budget is process-wide, not per remote endpoint: the OPC UA SDK's impersonation callback
/// (the <c>IOpcUaUserAuthenticator</c> seam) does not carry the client's address. Per-endpoint
/// accounting needs that seam widened in the OpcUaServer project — a follow-up, not this lane.
/// </para>
/// </remarks>
public sealed class ApiKeyStoreGuardOptions
{
/// <summary>The configuration section the knobs live in (shared with <c>ApiKeyOptions</c>).</summary>
public const string SectionName = "Security:ApiKeys";
/// <summary>
/// Whether the known-id set is consulted. <see langword="false"/> restores a store read per unknown
/// id. Only meaningful when the inner store can list ids (the ConfigDb store can; the artifact
/// store is never wrapped).
/// </summary>
public bool KnownIdSet { get; set; } = true;
/// <summary>
/// How many store MISSES (lookups that reach the inner store and find no key) may burst before
/// further lookups for unknown ids are refused without a store read. <c>0</c> disables the budget.
/// </summary>
public int MissBurst { get; set; } = 100;
/// <summary>How many miss tokens are restored per second, up to <see cref="MissBurst"/>.</summary>
public double MissRefillPerSecond { get; set; } = 20;
/// <summary>Binds the section, with defaults for anything absent.</summary>
/// <param name="configuration">The app configuration; null yields the defaults.</param>
/// <returns>The bound options.</returns>
public static ApiKeyStoreGuardOptions Bind(IConfiguration? configuration)
{
var options = new ApiKeyStoreGuardOptions();
configuration?.GetSection(SectionName).Bind(options);
return options;
}
}
@@ -56,6 +56,33 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
private readonly ConcurrentDictionary<string, Entry<ApiKeyLookup?>> _keys = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, Entry<ApiKeyLookup?>> _keys = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Entry<IReadOnlyList<NodeAcl>>> _grants = private readonly ConcurrentDictionary<string, Entry<IReadOnlyList<NodeAcl>>> _grants =
new(StringComparer.Ordinal); new(StringComparer.Ordinal);
private readonly ApiKeyStoreGuardOptions _guard;
// R03:3.3 — the known-id set: every ApiKeyId the inner store holds, refreshed every StoreCacheSeconds
// (single-flight; a refresh that fails or times out keeps the previous set). Null until the first
// successful load, or when the inner store cannot list ids — then the per-id path runs as before.
// The set and the instant it was loaded are published TOGETHER as one immutable snapshot behind a volatile
// reference (fix round 1): a reader on another thread can never pair a new set with an old stamp, or read
// a torn DateTimeOffset (a 16-byte struct, not atomically readable), as the separate fields allowed.
private volatile KnownIdSnapshot? _knownIds;
private readonly SemaphoreSlim _knownIdsRefresh = new(1, 1);
// Set when the inner store reports it cannot list ids. Per-instance on purpose: the guard options object is
// shared configuration and is never written (it used to be — `_guard.KnownIdSet = false` — which leaked
// one store's capability into every other consumer of the same options).
private volatile bool _listingUnsupported;
/// <summary>The known-id set and the instant it was loaded, published as one unit.</summary>
/// <param name="Ids">The ids the inner store holds.</param>
/// <param name="StampedUtc">When they were read.</param>
private sealed record KnownIdSnapshot(IReadOnlySet<string> Ids, DateTimeOffset StampedUtc);
// R03:3.3 — the miss budget: a token bucket on lookups that REACH the inner store and miss. Guards
// the path the known-id set cannot (set unavailable), and bounds a flood of unknown ids to the
// configured rate instead of one SQL round trip per attempt under the SessionManager lock.
private readonly object _bucketGate = new();
private double _missTokens;
private DateTimeOffset _missRefilledUtc;
/// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="BoundedApiKeyStore"/> class.</summary>
/// <param name="inner">The store being bounded.</param> /// <param name="inner">The store being bounded.</param>
@@ -66,11 +93,13 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
/// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a /// <c>DataPlaneAuthRegistration</c> for why a second clock here would be a hazard rather than a
/// detail. /// detail.
/// </param> /// </param>
/// <param name="guard">The pre-authentication guards (R03:3.3); defaults when omitted.</param>
public BoundedApiKeyStore( public BoundedApiKeyStore(
IApiKeyStore inner, IApiKeyStore inner,
IOptions<ApiKeyOptions> options, IOptions<ApiKeyOptions> options,
ILogger<BoundedApiKeyStore> log, ILogger<BoundedApiKeyStore> log,
TimeProvider time) TimeProvider time,
ApiKeyStoreGuardOptions? guard = null)
{ {
ArgumentNullException.ThrowIfNull(inner); ArgumentNullException.ThrowIfNull(inner);
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -81,8 +110,14 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
_options = options; _options = options;
_log = log; _log = log;
_time = time; _time = time;
_guard = guard ?? new ApiKeyStoreGuardOptions();
_missTokens = Math.Max(0, _guard.MissBurst);
_missRefilledUtc = time.GetUtcNow();
} }
/// <summary>The known-id set currently in force, or null when none has loaded. Exposed for tests and wiring guards.</summary>
public IReadOnlySet<string>? KnownKeyIds => _knownIds?.Ids;
/// <summary>The store being bounded.</summary> /// <summary>The store being bounded.</summary>
/// <remarks> /// <remarks>
/// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch /// Exposed so the composition root's wiring can be asserted. A decorator registered in one branch
@@ -115,12 +150,103 @@ public sealed class BoundedApiKeyStore : IApiKeyStore
return cached; return cached;
} }
// R03:3.3, guard 1 — an id the ConfigDb does not hold is refused in memory; the store is never asked.
var known = await KnownIdsAsync(ttl, ct).ConfigureAwait(false);
if (known is not null && !known.Contains(apiKeyId))
{
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "unknown_id"));
return null;
}
// R03:3.3, guard 2 — a lookup that will reach the store needs a miss token unless the id is known
// to exist (a known id is a real key being verified, never a guess).
if (known is null && !TryTakeMissToken())
{
OtOpcUaTelemetry.ApiKeyStoreBounded.Add(1, new KeyValuePair<string, object?>("outcome", "miss_budget"));
throw new InvalidOperationException(
"ApiKeyStore: the unknown-key miss budget is exhausted (Security:ApiKeys:MissBurst / "
+ "MissRefillPerSecond); refusing this lookup without a store read.");
}
var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct) var fresh = await BoundAsync(token => _inner.FindAsync(apiKeyId, utcNow, token), ct)
.ConfigureAwait(false); .ConfigureAwait(false);
if (fresh is not null && known is null) RefundMissToken();
if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh); if (ttl > TimeSpan.Zero) Store(_keys, apiKeyId, fresh);
return fresh; return fresh;
} }
/// <summary>
/// The known-id set, refreshed when older than <paramref name="ttl"/>. Single-flight: concurrent
/// callers during a refresh use the previous set (or, before the first load, the per-id path). A
/// refresh that fails or exceeds the deadline logs and keeps the previous set.
/// </summary>
/// <param name="ttl">The refresh interval (the cache TTL); non-positive disables the set.</param>
/// <param name="ct">The caller's token.</param>
/// <returns>The set, or null.</returns>
private async Task<IReadOnlySet<string>?> KnownIdsAsync(TimeSpan ttl, CancellationToken ct)
{
if (!_guard.KnownIdSet || _listingUnsupported || ttl <= TimeSpan.Zero) return null;
var now = _time.GetUtcNow();
var current = _knownIds;
if (current is not null && now - current.StampedUtc < ttl) return current.Ids;
if (!await _knownIdsRefresh.WaitAsync(0, ct).ConfigureAwait(false)) return current?.Ids;
try
{
// Re-check under the gate: another caller may have refreshed while this one waited.
if (_knownIds is { } refreshed && now - refreshed.StampedUtc < ttl) return refreshed.Ids;
var ids = await BoundAsync(token => _inner.ListKeyIdsAsync(token), ct).ConfigureAwait(false);
if (ids is null)
{
// Unsupported by the inner store: stop asking — for this decorator only.
_listingUnsupported = true;
return null;
}
_knownIds = new KnownIdSnapshot(ids, _time.GetUtcNow());
return ids;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
_log.LogWarning(ex,
"ApiKeyStore: refreshing the known key-id set failed; {Mode}",
current is null ? "unknown ids reach the store until it loads" : "the previous set stays in force");
return current?.Ids;
}
finally
{
_knownIdsRefresh.Release();
}
}
/// <summary>Takes one miss token, refilling by elapsed time first; always succeeds when the budget is disabled.</summary>
/// <returns><see langword="false"/> when the budget is exhausted.</returns>
private bool TryTakeMissToken()
{
var burst = _guard.MissBurst;
if (burst <= 0) return true;
lock (_bucketGate)
{
var now = _time.GetUtcNow();
var elapsed = (now - _missRefilledUtc).TotalSeconds;
if (elapsed > 0)
{
_missTokens = Math.Min(burst, _missTokens + elapsed * Math.Max(0, _guard.MissRefillPerSecond));
_missRefilledUtc = now;
}
if (_missTokens < 1) return false;
_missTokens -= 1;
return true;
}
}
/// <summary>A lookup that reached the store and HIT was not a miss: its token goes back.</summary>
private void RefundMissToken()
{
if (_guard.MissBurst <= 0) return;
lock (_bucketGate) _missTokens = Math.Min(_guard.MissBurst, _missTokens + 1);
}
// Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect. // Keyed by principal — the anonymous activation path, which runs on every unauthenticated connect.
/// <inheritdoc /> /// <inheritdoc />
public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) public async Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct)
@@ -125,6 +125,16 @@ public sealed class ConfigDbApiKeyStore : IApiKeyStore
return await LoadGrantsAsync(db, principal, ct).ConfigureAwait(false); return await LoadGrantsAsync(db, principal, ct).ConfigureAwait(false);
} }
/// <inheritdoc />
public async Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
{
await using var db = await _factory.CreateDbContextAsync(ct).ConfigureAwait(false);
// One projection over the whole (small) table — the unique index on ApiKeyId makes this an index
// scan — so an unknown id can be refused in memory instead of costing a query per attempt.
var ids = await db.ApiKeys.AsNoTracking().Select(k => k.ApiKeyId).ToListAsync(ct).ConfigureAwait(false);
return ids.ToHashSet(StringComparer.Ordinal);
}
/// <summary>Loads a principal's grants, confined to this node's cluster.</summary> /// <summary>Loads a principal's grants, confined to this node's cluster.</summary>
/// <remarks> /// <remarks>
/// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b>. The old /// <b>The full grant set, not just the <c>Cluster</c>-scoped rows</b>. The old
@@ -35,6 +35,16 @@ public interface IApiKeyStore
/// <param name="ct">The cancellation token.</param> /// <param name="ct">The cancellation token.</param>
/// <returns>The matching ACL rows; empty when none.</returns> /// <returns>The matching ACL rows; empty when none.</returns>
Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct); Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct);
/// <summary>
/// Every <c>ApiKeyId</c> the store holds (enabled or not), for the pre-authentication known-id set
/// (R03:3.3); <see langword="null"/> when the store cannot cheaply list them, in which case callers
/// fall back to a per-id lookup. The set only answers "does this id exist at all" — enabled, expiry
/// and the secret are still decided by <see cref="FindAsync"/>.
/// </summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>The id set, or null when unsupported.</returns>
Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct) => Task.FromResult<IReadOnlySet<string>?>(null);
} }
/// <summary>An API key resolved for authentication, with the ACL grants naming its principal.</summary> /// <summary>An API key resolved for authentication, with the ACL grants naming its principal.</summary>
@@ -0,0 +1,73 @@
namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
/// <summary>Lifecycle of this node's OPC UA endpoint as <see cref="OtOpcUaServerHostedService"/> drives it.</summary>
public enum OpcUaEndpointStatus
{
/// <summary>The hosted service has not run <c>StartAsync</c> yet.</summary>
NotStarted,
/// <summary>The SDK is listening and the address-space / ServiceLevel sinks are bound.</summary>
Started,
/// <summary>The SDK failed to start (port in use, PKI unwritable, bad <c>OpcUa:*</c> override, …).</summary>
Failed,
/// <summary>The hosted service stopped the SDK; the port is released.</summary>
Stopped,
}
/// <summary>
/// The one place the "did the OPC UA SDK actually start?" fact lives (R03:2.1). Before it, a failed
/// start was one Error line: <c>/health/ready</c> stayed 200, the node joined its mesh, could be
/// elected driver Primary and acked deploys as Applied through the Null sink — while clients got
/// connection-refused from the node the fleet called healthy.
/// </summary>
/// <remarks>
/// Consumed by <c>OpcUaEndpointHealthCheck</c> (Ready + Active tiers). The redundancy election and the
/// ServiceLevel calculator do not read it yet — they live outside the Host (<c>Cluster</c> /
/// <c>Runtime</c>); feeding them is the Wave 3 follow-up the lane report names. Until then a fused
/// node whose SDK failed is visible (503) but still Primary-eligible.
/// </remarks>
public interface IOpcUaEndpointState
{
/// <summary>The endpoint's current lifecycle status.</summary>
OpcUaEndpointStatus Status { get; }
/// <summary>Why the start failed, when <see cref="Status"/> is <see cref="OpcUaEndpointStatus.Failed"/>.</summary>
string? FailureReason { get; }
}
/// <summary>Mutable <see cref="IOpcUaEndpointState"/>; written by the hosted service, read by the health check.</summary>
public sealed class OpcUaEndpointState : IOpcUaEndpointState
{
private volatile OpcUaEndpointStatus _status = OpcUaEndpointStatus.NotStarted;
private volatile string? _failureReason;
/// <inheritdoc />
public OpcUaEndpointStatus Status => _status;
/// <inheritdoc />
public string? FailureReason => _failureReason;
/// <summary>Records a successful SDK start.</summary>
public void MarkStarted()
{
_failureReason = null;
_status = OpcUaEndpointStatus.Started;
}
/// <summary>Records a failed SDK start.</summary>
/// <param name="reason">The failure, as the health probe should report it.</param>
public void MarkFailed(string reason)
{
_failureReason = reason;
_status = OpcUaEndpointStatus.Failed;
}
/// <summary>Records that the hosted service stopped the SDK.</summary>
public void MarkStopped()
{
_failureReason = null;
_status = OpcUaEndpointStatus.Stopped;
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization; using ZB.MOM.WW.OtOpcUa.Core.Authorization;
@@ -48,6 +49,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
private readonly IDriverPrimaryRoleSource? _primaryRoleSource; private readonly IDriverPrimaryRoleSource? _primaryRoleSource;
private readonly HistorianUpdaterHolder _historianUpdater; private readonly HistorianUpdaterHolder _historianUpdater;
private readonly IHistoryUpdateRoleView? _historyUpdateRoleView; private readonly IHistoryUpdateRoleView? _historyUpdateRoleView;
private readonly OpcUaEndpointState _endpointState;
private readonly bool _rethrowStartFailure;
private OpcUaApplicationHost? _appHost; private OpcUaApplicationHost? _appHost;
private OtOpcUaSdkServer? _server; private OtOpcUaSdkServer? _server;
@@ -103,6 +106,13 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
/// <param name="historyUpdateRoleView">The HistoryUpdate verdict <c>DriverHostActor</c> publishes (the /// <param name="historyUpdateRoleView">The HistoryUpdate verdict <c>DriverHostActor</c> publishes (the
/// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every /// data-plane Primary verdict, closed until this node's first redundancy snapshot). Optional: registered on every
/// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).</param> /// driver-role node; an admin-only node has none and wires no HistoryUpdate Primary gate (allow).</param>
/// <param name="endpointState">The endpoint lifecycle fact this service maintains for the
/// <c>opcua-endpoint</c> health probe (R03:2.1). Optional: a private instance is kept when none is registered,
/// so a harness that never probes health is unchanged.</param>
/// <param name="roles">The node's resolved roles. A node with the driver role and NO admin role exists only
/// to serve this endpoint, so a failed SDK start RETHROWS there (the host's fatal handler exits non-zero and
/// the supervisor restarts the process); a fused node keeps its AdminUI up and reports the failure through
/// the health probe instead. Null (harnesses) behaves as fused.</param>
public OtOpcUaServerHostedService( public OtOpcUaServerHostedService(
IOptions<OpcUaApplicationHostOptions> options, IOptions<OpcUaApplicationHostOptions> options,
DeferredAddressSpaceSink deferredSink, DeferredAddressSpaceSink deferredSink,
@@ -119,7 +129,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IDriverPrimaryRoleSource? primaryRoleSource = null, IDriverPrimaryRoleSource? primaryRoleSource = null,
HistorianUpdaterHolder? historianUpdater = null, HistorianUpdaterHolder? historianUpdater = null,
IHistoryUpdateRoleView? historyUpdateRoleView = null) IHistoryUpdateRoleView? historyUpdateRoleView = null,
OpcUaEndpointState? endpointState = null,
EffectiveRoles? roles = null)
{ {
ArgumentNullException.ThrowIfNull(aclOptions); ArgumentNullException.ThrowIfNull(aclOptions);
ArgumentNullException.ThrowIfNull(time); ArgumentNullException.ThrowIfNull(time);
@@ -144,6 +156,8 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
_primaryRoleSource = primaryRoleSource; _primaryRoleSource = primaryRoleSource;
_historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None; _historianUpdater = historianUpdater ?? HistorianUpdaterHolder.None;
_historyUpdateRoleView = historyUpdateRoleView; _historyUpdateRoleView = historyUpdateRoleView;
_endpointState = endpointState ?? new OpcUaEndpointState();
_rethrowStartFailure = roles is { HasDriver: true, HasAdmin: false };
_logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>(); _logger = loggerFactory.CreateLogger<OtOpcUaServerHostedService>();
} }
@@ -166,11 +180,22 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
} }
catch (Exception ex) catch (Exception ex)
{ {
// R03:2.1 — the failure is a FACT now, not a log line: the opcua-endpoint probe turns Unhealthy
// (ready + active 503) and a driver-only node, which exists only to serve this endpoint, exits
// so its supervisor restarts it. A fused node keeps its AdminUI up so the operator can correct
// the OpcUa:* config without a process bounce — but it is visibly not ready.
_endpointState.MarkFailed(ex.Message);
await ReleaseFailedStartAsync().ConfigureAwait(false);
if (_rethrowStartFailure)
{
_logger.LogCritical(ex,
"OtOpcUaServerHostedService: SDK start failed on a driver-only node — nothing else on this " +
"node justifies staying up; failing host start so the supervisor restarts the process");
throw;
}
_logger.LogError(ex, _logger.LogError(ex,
"OtOpcUaServerHostedService: SDK start failed; OpcUaPublishActor writes will continue to no-op"); "OtOpcUaServerHostedService: SDK start failed; /health/ready reports Unhealthy and " +
// Don't rethrow — the rest of the host (admin UI, driver actors, etc.) can still boot. "OpcUaPublishActor writes will continue to no-op until the host is restarted with a corrected OpcUa section");
// Operators see the failure via the logs + can correct config without a process bounce
// of the whole binary.
return; return;
} }
@@ -178,6 +203,7 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{ {
_logger.LogWarning( _logger.LogWarning(
"OtOpcUaServerHostedService: SDK reported started but NodeManager is null; sink stays Null"); "OtOpcUaServerHostedService: SDK reported started but NodeManager is null; sink stays Null");
_endpointState.MarkFailed("SDK reported started but the node manager is null");
return; return;
} }
@@ -352,22 +378,16 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
{ {
_logger.LogError(ex, _logger.LogError(ex,
"OtOpcUaServerHostedService: ServiceLevel binding failed; stopping the OPC UA endpoint while the admin host continues"); "OtOpcUaServerHostedService: ServiceLevel binding failed; stopping the OPC UA endpoint while the admin host continues");
_endpointState.MarkFailed("ServiceLevel binding failed: " + ex.Message);
try try
{ {
await StopAsync(cancellationToken).ConfigureAwait(false); DetachSinks();
} }
catch (Exception cleanupError) catch (Exception cleanupError)
{ {
_logger.LogError(cleanupError, "OtOpcUaServerHostedService: failed to detach OPC UA bindings after startup failure"); _logger.LogError(cleanupError, "OtOpcUaServerHostedService: failed to detach OPC UA bindings after startup failure");
} }
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync( await ReleaseEndpointAsync().ConfigureAwait(false);
async () => await _server.StopAsync(CancellationToken.None).ConfigureAwait(false),
() => _appHost.DisposeAsync(), _logger).ConfigureAwait(false);
if (released)
{
_appHost = null;
_server = null;
}
return; return;
} }
@@ -375,9 +395,52 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// attached, so the first tick cannot land on a half-wired server. // attached, so the first tick cannot land on a half-wired server.
StartRevalidationTimer(); StartRevalidationTimer();
_endpointState.MarkStarted();
_logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound"); _logger.LogInformation("OtOpcUaServerHostedService: SDK started, address-space + ServiceLevel sinks bound");
} }
/// <summary>
/// Releases an SDK whose <c>StartAsync</c> threw. The application instance may hold a half-opened
/// listener (the port-in-use case binds nothing, but a certificate or PKI failure can fire after the
/// transport is up); disposing it is the only way to be sure. The state stays Failed.
/// </summary>
private async Task ReleaseFailedStartAsync()
{
var appHost = _appHost;
_appHost = null;
_server = null;
if (appHost is null) return;
try
{
await appHost.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OtOpcUaServerHostedService: disposing the application host after a failed start threw");
}
}
/// <summary>
/// Stops the SDK and disposes the application host, releasing endpoint ownership only when both
/// succeeded (<see cref="FailedServiceLevelBindingCleanup.StopAndDisposeAsync"/>): on a failure the
/// fields are retained so <see cref="DisposeAsync"/> retries at host disposal.
/// </summary>
private async Task ReleaseEndpointAsync()
{
var server = _server;
var appHost = _appHost;
if (server is null && appHost is null) return;
var released = await FailedServiceLevelBindingCleanup.StopAndDisposeAsync(
async () => { if (server is not null) await server.StopAsync(CancellationToken.None).ConfigureAwait(false); },
() => appHost?.DisposeAsync() ?? ValueTask.CompletedTask,
_logger).ConfigureAwait(false);
if (released)
{
_appHost = null;
_server = null;
}
}
/// <summary> /// <summary>
/// The <c>ServerHistorian:HonorReturnBounds</c> kill switch <c>StartAsync</c> applies to the node manager /// The <c>ServerHistorian:HonorReturnBounds</c> kill switch <c>StartAsync</c> applies to the node manager
/// (default <c>true</c> when the section or key is absent). Exposed so the wiring is testable without booting the SDK. /// (default <c>true</c> when the section or key is absent). Exposed so the wiring is testable without booting the SDK.
@@ -441,11 +504,30 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
} }
/// <summary> /// <summary>
/// Stops the OPC UA server asynchronously. /// Stops the OPC UA server asynchronously: detaches every sink, then STOPS the SDK so the port closes.
/// </summary> /// </summary>
/// <remarks>
/// R03:1.4 — the SDK used to stay listening from here until host disposal (Akka coordinated shutdown,
/// the other hosted services' StopAsync), serving the last-written values with an emptied authority:
/// reads succeeded on stale data while writes got "unavailable", and clients saw a server that was
/// up but inert instead of the connection-refused that triggers their failover. <see cref="DisposeAsync"/>
/// stays idempotent with this: ownership is nulled once the stop succeeded.
/// </remarks>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
public Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{
DetachSinks();
await ReleaseEndpointAsync().ConfigureAwait(false);
if (_endpointState.Status != OpcUaEndpointStatus.Failed) _endpointState.MarkStopped();
}
/// <summary>
/// Reverts every seam on the node manager to its Null adapter so in-flight work from a stopping actor
/// or a late client call cannot hit a half-disposed server. Pure detach — the SDK keeps running
/// until <see cref="ReleaseEndpointAsync"/>.
/// </summary>
private void DetachSinks()
{ {
// FIRST, ahead of every detach below (arch-review 03/S19). A tick landing on a node manager whose // FIRST, ahead of every detach below (arch-review 03/S19). A tick landing on a node manager whose
// authority and gate have already been cleared is the shape this whole teardown block exists to // authority and gate have already been cleared is the shape this whole teardown block exists to
@@ -472,7 +554,6 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
// request rather than let the seams fall back to their role checks — a stopping node should shed // request rather than let the seams fall back to their role checks — a stopping node should shed
// enforcement in the same order it acquired it. // enforcement in the same order it acquired it.
_server?.SetDataPlaneAuthorizer(null); _server?.SetDataPlaneAuthorizer(null);
return Task.CompletedTask;
} }
/// <summary> /// <summary>
@@ -482,10 +563,15 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
// Before the app host, and idempotent with StopAsync — a host that disposes without stopping must // Before the app host, and idempotent with StopAsync — a host that disposes without stopping must
// not leave a tick armed against a server that is about to go away. // not leave a tick armed against a server that is about to go away. A StopAsync that released the
// endpoint left both fields null, so this is a no-op after it; a stop that FAILED to release left
// them set, and this is the retry.
_revalidationTimer?.Dispose(); _revalidationTimer?.Dispose();
_revalidationTimer = null; _revalidationTimer = null;
if (_appHost is not null) await _appHost.DisposeAsync(); var appHost = _appHost;
_appHost = null;
_server = null;
if (appHost is not null) await appHost.DisposeAsync();
} }
} }
+17 -6
View File
@@ -162,10 +162,15 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua");
// central over the fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it // central over the fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it
// unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string. // unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string.
// A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected. // A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected.
// bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it // A driver node needs the pepper to VERIFY keys, an admin node needs it to MINT them, and getting this
// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a // wrong on either side used to be a silent authentication failure rather than a startup error (R03:2.3):
// startup error. // ApiKeyOptionsValidator now fails host start on a blank or placeholder pepper wherever either role is
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); // present. A node with neither role (dev-only) has no key path and keeps the plain bind.
if (hasDriver || hasAdmin)
builder.Services.AddValidatedOptions<ApiKeyOptions, ApiKeyOptionsValidator>(
builder.Configuration, ApiKeyOptions.SectionName);
else
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection(ApiKeyOptions.SectionName));
// Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config // Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config
// change rather than a redeploy, and what it swaps rather than adds. // change rather than a redeploy, and what it swaps rather than adds.
builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName)); builder.Services.Configure<AclOptions>(builder.Configuration.GetSection(AclOptions.SectionName));
@@ -452,6 +457,11 @@ if (hasDriver)
// mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the // mediator (for the inbound alarm-command router) without racing Akka startup — same pattern the
// DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once. // DpsScriptLogPublisher above uses. TryAdd so a fused admin+driver node registers it exactly once.
builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>()); builder.Services.TryAddSingleton<Func<ActorSystem>>(sp => () => sp.GetRequiredService<ActorSystem>());
// The "did the SDK start?" fact (R03:2.1): written by the hosted service, read by the opcua-endpoint
// health probe (AddOtOpcUaHealth below, hasDriver). One instance behind both the class and the
// interface, so the writer and the probe cannot disagree.
builder.Services.AddSingleton<OpcUaEndpointState>();
builder.Services.AddSingleton<IOpcUaEndpointState>(sp => sp.GetRequiredService<OpcUaEndpointState>());
builder.Services.AddHostedService<OtOpcUaServerHostedService>(); builder.Services.AddHostedService<OtOpcUaServerHostedService>();
} }
@@ -554,8 +564,9 @@ if (hasAdmin)
builder.Services.AddOtOpcUaSecrets(builder.Configuration); builder.Services.AddOtOpcUaSecrets(builder.Configuration);
// hasAdmin gates the configdb probe: a driver-only node registers no ConfigDb factory (see above), so // hasAdmin gates the configdb probe: a driver-only node registers no ConfigDb factory (see above), so
// probing it would throw when the readiness endpoint resolves the factory. // probing it would throw when the readiness endpoint resolves the factory. hasDriver adds the
builder.Services.AddOtOpcUaHealth(hasAdmin); // opcua-endpoint probe (ready + active): a driver node whose SDK failed to start is Unhealthy, not green.
builder.Services.AddOtOpcUaHealth(hasAdmin, hasDriver);
builder.Services.AddOtOpcUaObservability(builder.Configuration); builder.Services.AddOtOpcUaObservability(builder.Configuration);
// gRPC server plumbing shared by three endpoints: the LocalDb passive sync endpoint (driver-role, // gRPC server plumbing shared by three endpoints: the LocalDb passive sync endpoint (driver-role,
@@ -0,0 +1,95 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
/// <summary>
/// <see cref="IAuditActorAccessor"/> that reads the Blazor circuit's principal first (R03:2.5).
/// </summary>
/// <remarks>
/// <para>
/// <see cref="HttpAuditActorAccessor"/> reads <c>IHttpContextAccessor</c>, which is right for a
/// minimal-API request but wrong inside a Blazor Server circuit: after the first render there is no
/// current <c>HttpContext</c> on the circuit's threads, so every audit row written from an interactive
/// page would have been stamped <c>"system"</c>. The circuit's identity lives in the scoped
/// <see cref="AuthenticationStateProvider"/>, so that is consulted first and the HTTP context is the
/// fallback for non-circuit scopes (where the provider is not registered at all).
/// </para>
/// <para>
/// <c>CurrentActor</c> is synchronous by contract; the server-side provider's
/// <see cref="AuthenticationStateProvider.GetAuthenticationStateAsync"/> is already completed in a
/// circuit, and an incomplete task is treated as "no circuit identity" rather than blocked on.
/// </para>
/// </remarks>
public sealed class AuthenticationStateAuditActorAccessor : IAuditActorAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly AuthenticationStateProvider? _authenticationState;
/// <summary>Initializes a new instance of the <see cref="AuthenticationStateAuditActorAccessor"/> class.</summary>
/// <param name="httpContextAccessor">The HTTP context accessor (minimal-API fallback).</param>
/// <param name="authenticationState">The circuit's authentication state, when resolved inside a Blazor circuit.</param>
public AuthenticationStateAuditActorAccessor(
IHttpContextAccessor httpContextAccessor,
AuthenticationStateProvider? authenticationState = null)
{
ArgumentNullException.ThrowIfNull(httpContextAccessor);
_httpContextAccessor = httpContextAccessor;
_authenticationState = authenticationState;
}
/// <inheritdoc />
public string? CurrentActor
{
get
{
if (_authenticationState is not null)
{
var actor = TryResolveCircuitActor(_authenticationState);
if (actor is not null) return actor;
}
return ResolveActor(_httpContextAccessor.HttpContext?.User);
}
}
/// <summary>
/// The circuit's actor, or null when this scope has no circuit identity. The server-side provider
/// is registered for EVERY scope on an AdminUI node, but only a circuit calls
/// <c>SetAuthenticationState</c>; anywhere else (a minimal-API request, a hosted service's scope)
/// <c>GetAuthenticationStateAsync</c> throws <see cref="InvalidOperationException"/> synchronously
/// (fix round 1 / I5). That is "no circuit", never an audit failure — the caller falls back to
/// the HTTP context.
/// </summary>
/// <param name="provider">The scoped provider.</param>
/// <returns>The actor, or null.</returns>
private static string? TryResolveCircuitActor(AuthenticationStateProvider provider)
{
Task<AuthenticationState> task;
try
{
task = provider.GetAuthenticationStateAsync();
}
catch (InvalidOperationException)
{
return null;
}
return task.IsCompletedSuccessfully ? ResolveActor(task.Result.User) : null;
}
/// <summary>The claim-resolution rule shared with <see cref="HttpAuditActorAccessor"/>.</summary>
/// <param name="user">The principal, or null.</param>
/// <returns>The actor string, or null when unauthenticated.</returns>
internal static string? ResolveActor(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true)
return null;
// Prefer the canonical login-name claim; fall back to the Name claim or
// Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name).
return user.FindFirst(ZbClaimTypes.Username)?.Value
?? user.FindFirst(ZbClaimTypes.Name)?.Value
?? user.Identity.Name;
}
}
@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.OtOpcUa.Security.Audit; namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
@@ -17,10 +16,10 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Audit;
/// <see cref="System.Security.Principal.IIdentity.Name"/>, in that order.</item> /// <see cref="System.Security.Principal.IIdentity.Name"/>, in that order.</item>
/// </list> /// </list>
/// <para> /// <para>
/// Registered as <b>scoped</b> in <see cref="ZB.MOM.WW.OtOpcUa.Security.ServiceCollectionExtensions.AddOtOpcUaAuth"/> /// No longer the registered implementation: <see cref="AuthenticationStateAuditActorAccessor"/> is, because
/// so that it correctly follows the request scope used by Blazor Server interactive components /// a Blazor circuit has no <c>HttpContext</c> after its first render and this accessor would stamp every row
/// and minimal-API endpoints. <c>IHttpContextAccessor</c> is registered by /// written from an interactive page <c>"system"</c>. Kept as the explicit HTTP-only variant for non-circuit
/// <c>AddOtOpcUaAuth</c> via <c>services.AddHttpContextAccessor()</c>. /// hosts and tests.
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class HttpAuditActorAccessor : IAuditActorAccessor public sealed class HttpAuditActorAccessor : IAuditActorAccessor
@@ -35,19 +34,6 @@ public sealed class HttpAuditActorAccessor : IAuditActorAccessor
} }
/// <inheritdoc /> /// <inheritdoc />
public string? CurrentActor public string? CurrentActor =>
{ AuthenticationStateAuditActorAccessor.ResolveActor(_httpContextAccessor.HttpContext?.User);
get
{
var user = _httpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true)
return null;
// Prefer the canonical login-name claim; fall back to the Name claim or
// Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name).
return user.FindFirst(ZbClaimTypes.Username)?.Value
?? user.FindFirst(ZbClaimTypes.Name)?.Value
?? user.Identity.Name;
}
}
} }
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
namespace ZB.MOM.WW.OtOpcUa.Security.Auth;
/// <summary>The authentication scheme names OtOpcUa registers.</summary>
public static class OtOpcUaAuthSchemes
{
/// <summary>
/// The bearer scheme that validates the HS256 tokens <c>/auth/token</c> mints: the first-party
/// <c>AddJwtBearer</c> handler over <see cref="JwtTokenService.BuildValidationParameters"/>.
/// Registered only when <see cref="JwtOptions.IsConfigured"/>.
/// </summary>
public const string Bearer = "Bearer";
/// <summary>
/// The default scheme: a policy scheme that forwards to <see cref="Bearer"/> when the request
/// carries an <c>Authorization: Bearer</c> header and to the cookie scheme otherwise, so one
/// <c>RequireAuthorization()</c> / policy accepts a browser session AND a machine token.
/// </summary>
public const string CookieOrBearer = "OtOpcUa.CookieOrBearer";
/// <summary>Whether the request presents a bearer token (the selector's whole rule).</summary>
/// <param name="context">The request.</param>
/// <returns><see langword="true"/> when an <c>Authorization: Bearer …</c> header is present.</returns>
public static bool HasBearerToken(HttpContext context) =>
TryGetBearerToken(context.Request.Headers.Authorization, out _);
internal static bool TryGetBearerToken(StringValues authorization, out string token)
{
token = string.Empty;
var header = authorization.ToString();
const string prefix = "Bearer ";
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
token = header[prefix.Length..].Trim();
return token.Length > 0;
}
}
@@ -1,79 +0,0 @@
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Security.Blazor;
/// <summary>
/// Blazor Server <see cref="AuthenticationStateProvider"/> that snapshots the cookie-backed
/// principal supplied at circuit boot and polls <c>/auth/ping</c> every 60 seconds to detect
/// expiry. Mirrors ScadaLink's CentralUI implementation.
/// </summary>
public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAsyncDisposable
{
private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60);
private readonly HttpClient _http;
private readonly ILogger<CookieAuthenticationStateProvider> _logger;
private readonly CancellationTokenSource _cts = new();
private ClaimsPrincipal _current;
private Task? _pingLoop;
/// <summary>Initializes a new instance of the CookieAuthenticationStateProvider class.</summary>
/// <param name="initial">The initial claims principal from circuit boot.</param>
/// <param name="http">The HTTP client for authentication ping requests.</param>
/// <param name="logger">The logger for diagnostic messages.</param>
public CookieAuthenticationStateProvider(
ClaimsPrincipal initial,
HttpClient http,
ILogger<CookieAuthenticationStateProvider> logger)
{
_current = initial;
_http = http;
_logger = logger;
}
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
_pingLoop ??= Task.Run(() => PingLoopAsync(_cts.Token));
return Task.FromResult(new AuthenticationState(_current));
}
private async Task PingLoopAsync(CancellationToken ct)
{
try
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(PingInterval, ct).ConfigureAwait(false);
var resp = await _http.GetAsync("/auth/ping", ct).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode && _current.Identity?.IsAuthenticated == true)
{
_logger.LogInformation("/auth/ping returned {Code}; notifying circuit", (int)resp.StatusCode);
_current = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(
Task.FromResult(new AuthenticationState(_current)));
}
}
}
catch (OperationCanceledException) { /* expected on shutdown */ }
catch (Exception ex)
{
_logger.LogWarning(ex, "Auth ping loop terminated unexpectedly");
}
}
/// <summary>Cancels the background ping loop, awaits its completion, and disposes the cancellation token source.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
_cts.Cancel();
if (_pingLoop is not null)
{
try { await _pingLoop.ConfigureAwait(false); } catch { /* swallow shutdown errors */ }
}
_cts.Dispose();
}
}
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.OtOpcUa.Security.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Jwt;
@@ -23,6 +24,17 @@ public static class AuthEndpoints
/// <summary>Response for a token issue request.</summary> /// <summary>Response for a token issue request.</summary>
public sealed record TokenResponse(string Token); public sealed record TokenResponse(string Token);
/// <summary>
/// The <c>/auth/token</c> policy: an authenticated principal from the COOKIE scheme. Naming the scheme
/// makes the authorization middleware authenticate that scheme alone, so an <c>Authorization: Bearer</c>
/// header is never consulted here (with login disabled the auto-login handler sits under the same
/// scheme name, so the dev rig is unchanged).
/// </summary>
internal static readonly AuthorizationPolicy CookieSessionOnly =
new AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)
.RequireAuthenticatedUser()
.Build();
/// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary> /// <summary>Maps OtOpcUa authentication endpoints to the application route builder.</summary>
/// <param name="app">The endpoint route builder.</param> /// <param name="app">The endpoint route builder.</param>
/// <returns>The endpoint route builder for chaining.</returns> /// <returns>The endpoint route builder for chaining.</returns>
@@ -36,7 +48,17 @@ public static class AuthEndpoints
// AddOtOpcUaAuth fallback policy that would otherwise 401 the request. // AddOtOpcUaAuth fallback policy that would otherwise 401 the request.
app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery(); app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery();
app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous(); app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous();
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(); // Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured
// endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call.
if (app.ServiceProvider.GetRequiredService<IOptions<JwtOptions>>().Value.IsConfigured)
// COOKIE scheme only (fix round 1 / I2): the default scheme also accepts a bearer, and a bearer
// that could mint a fresh token would renew itself forever — outliving its own expiry and the
// user's LDAP membership. A token is minted only for an interactive (cookie) session.
app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(CookieSessionOnly);
else
app.ServiceProvider.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogInformation("Security:Jwt:SigningKey is not configured — /auth/token is not mapped and bearer tokens are not accepted");
app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization(); app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization();
return app; return app;
} }
@@ -73,8 +95,13 @@ public static class AuthEndpoints
{ {
result = await ldap.AuthenticateAsync(username, password, ct); result = await ldap.AuthenticateAsync(username, password, ct);
} }
catch (Exception) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
// R03:2.7 — a directory outage used to leave NOTHING in the server log from this path; the
// operator had to infer every 503 from the directory side.
http.RequestServices.GetService<ILoggerFactory>()?
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
.LogWarning(ex, "LDAP authentication failed for {User}; answering 503", username);
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
} }
@@ -15,4 +15,26 @@ public sealed class JwtOptions
/// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary> /// <summary>Default token expiry. Mirrors ScadaLink (15 min).</summary>
public int ExpiryMinutes { get; set; } = 15; public int ExpiryMinutes { get; set; } = 15;
/// <summary>
/// Whether the JWT feature is on: a non-blank <see cref="SigningKey"/>. When off, <c>/auth/token</c>
/// is not mapped and no bearer scheme is registered, so an admin node that never configured a key
/// keeps booting exactly as before; when on, the key is validated at host start
/// (<c>ValidateOnStart</c>) instead of throwing HTTP 500 on the first token request.
/// </summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(SigningKey);
/// <summary>The validation rule shared by the startup validator and the tests: why the options are invalid, or null.</summary>
/// <returns>A failure message, or <see langword="null"/> when valid.</returns>
public string? Validate()
{
if (!IsConfigured) return null;
var keyBytes = System.Text.Encoding.UTF8.GetByteCount(SigningKey);
if (keyBytes < MinSigningKeyBytes)
return $"{SectionName}:SigningKey must be at least {MinSigningKeyBytes} bytes (256 bits) UTF-8 for HMAC-SHA256; the configured key is {keyBytes} byte(s).";
if (string.IsNullOrWhiteSpace(Issuer)) return $"{SectionName}:Issuer must not be blank.";
if (string.IsNullOrWhiteSpace(Audience)) return $"{SectionName}:Audience must not be blank.";
if (ExpiryMinutes <= 0) return $"{SectionName}:ExpiryMinutes must be > 0.";
return null;
}
} }
@@ -0,0 +1,19 @@
using Microsoft.Extensions.Options;
namespace ZB.MOM.WW.OtOpcUa.Security.Jwt;
/// <summary>
/// Startup validator for <see cref="JwtOptions"/> (R03:2.4): when a signing key is configured it must
/// be usable, and the host must learn that at start (<c>ValidateOnStart</c>) rather than from an HTTP 500
/// on the first <c>/auth/token</c>. An unconfigured section (no key) is valid — the feature is simply off.
/// </summary>
public sealed class JwtOptionsValidator : IValidateOptions<JwtOptions>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, JwtOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var failure = options.Validate();
return failure is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failure);
}
}
@@ -25,24 +25,12 @@ public sealed class JwtTokenService
/// <summary> /// <summary>
/// Role claim type used in the JWT payload. /// Role claim type used in the JWT payload.
/// <para> /// <para>
/// <b>Issued-only / no internal JwtBearer scheme:</b> OtOpcUa uses a single Cookie /// The short "Role" key is what <see cref="Issue"/> mints and what external consumers of the token
/// authentication scheme; the JWT is minted by the <c>/auth/token</c> endpoint and /// expect. The internal bearer scheme (<c>AddJwtBearer</c> in <c>AddOtOpcUaAuth</c>) validates with
/// consumed externally (e.g. by OPC-UA clients or automation scripts). There is no /// <see cref="BuildValidationParameters"/>, which sets <c>RoleClaimType = RoleClaimType</c> and
/// <c>AddJwtBearer</c> pipeline in OtOpcUa — the cookie stores the /// <c>NameClaimType = UsernameClaimType</c>, and turns <c>MapInboundClaims</c> off, so
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> directly. Because no internal /// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve against this key. Never
/// bearer validation path exists, the short "Role" key is intentionally used here rather /// construct <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> ad hoc.
/// than the long <see cref="ClaimTypes.Role"/> URI; external consumers receive exactly the
/// key they expect.
/// </para>
/// <para>
/// <b>If a JwtBearer scheme is ever added:</b> the
/// <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> passed to
/// <c>AddJwtBearer</c> MUST set <c>RoleClaimType = JwtTokenService.RoleClaimType</c> (and
/// <c>NameClaimType = JwtTokenService.UsernameClaimType</c>) so that
/// <c>[Authorize(Roles=...)]</c> and <c>ClaimsPrincipal.IsInRole</c> resolve correctly.
/// <see cref="BuildValidationParameters"/> is already wired to do this and MUST be used
/// rather than constructing <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/>
/// ad hoc.
/// </para> /// </para>
/// </summary> /// </summary>
public const string RoleClaimType = "Role"; public const string RoleClaimType = "Role";
@@ -150,5 +138,9 @@ public sealed class JwtTokenService
// if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale. // if this is ever passed to AddJwtBearer. See RoleClaimType doc comment for rationale.
RoleClaimType = RoleClaimType, RoleClaimType = RoleClaimType,
NameClaimType = UsernameClaimType, NameClaimType = UsernameClaimType,
// Pinned to the one algorithm Issue signs with: an unsigned (alg:none) token or one signed with a
// different HMAC variant over the same key is refused on both the bearer and TryValidate paths.
ValidAlgorithms = [SecurityAlgorithms.HmacSha256],
RequireSignedTokens = true,
}; };
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -19,9 +20,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security; namespace ZB.MOM.WW.OtOpcUa.Security;
/// <summary> /// <summary>
/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the /// DI registration for OtOpcUa auth. The cookie scheme carries the browser session; when
/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge /// <c>Security:Jwt:SigningKey</c> is configured a <see cref="OtOpcUaAuthSchemes.Bearer"/> scheme
/// structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>. /// accepts the tokens <c>/auth/token</c> mints (R03:2.4), and the default scheme is a policy scheme
/// that forwards to whichever the request presents — so <c>/api/*</c> and every
/// <c>RequireAuthorization</c> endpoint take a browser session OR a machine token. Matches
/// ScadaBridge structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
/// </summary> /// </summary>
public static class ServiceCollectionExtensions public static class ServiceCollectionExtensions
{ {
@@ -36,7 +40,14 @@ public static class ServiceCollectionExtensions
/// <returns>The same service collection, for chaining.</returns> /// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName)); // JWT is opt-in by key: with no SigningKey the feature is off (no /auth/token, no bearer scheme)
// and an admin node boots as before; with one, the key is validated at host START (R03:2.4) —
// the JwtTokenService constructor used to throw on the first /auth/token call (HTTP 500).
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<JwtOptions>, JwtOptionsValidator>();
var jwtConfigured = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()?.IsConfigured ?? false;
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName)); services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName)); services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
@@ -53,7 +64,10 @@ public static class ServiceCollectionExtensions
// as the Actor field when constructing a canonical ZB.MOM.WW.Audit.AuditEvent. Registered // as the Actor field when constructing a canonical ZB.MOM.WW.Audit.AuditEvent. Registered
// Scoped so it correctly follows the request scope used by Blazor Server and minimal-API // Scoped so it correctly follows the request scope used by Blazor Server and minimal-API
// endpoints. // endpoints.
services.TryAddScoped<IAuditActorAccessor, HttpAuditActorAccessor>(); // AuthenticationStateAuditActorAccessor reads the Blazor circuit's principal first (no HttpContext
// exists on a circuit's threads after the first render) and falls back to the HTTP context for
// minimal-API scopes, where AuthenticationStateProvider is not registered (R03:2.5).
services.TryAddScoped<IAuditActorAccessor, AuthenticationStateAuditActorAccessor>();
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it // Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton // wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
@@ -78,7 +92,39 @@ public static class ServiceCollectionExtensions
.GetSection(AuthDisableLoginOptions.SectionName) .GetSection(AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin)); .GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); // The default scheme is the cookie-or-bearer selector; sign-in/out stay on the cookie scheme, which is
// the only one that can issue a session. With JWT off the selector always picks the cookie.
var authBuilder = services.AddAuthentication(o =>
{
o.DefaultScheme = OtOpcUaAuthSchemes.CookieOrBearer;
o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme;
});
authBuilder.AddPolicyScheme(OtOpcUaAuthSchemes.CookieOrBearer, "Cookie or Bearer", o =>
{
o.ForwardDefaultSelector = ctx =>
jwtConfigured && OtOpcUaAuthSchemes.HasBearerToken(ctx)
? OtOpcUaAuthSchemes.Bearer
: CookieAuthenticationDefaults.AuthenticationScheme;
});
if (jwtConfigured)
{
// First-party JwtBearer (R03:2.4, fix round 1 / I1) — the earlier hand-rolled handler is gone.
// Validation parameters come from the SAME JwtTokenService that mints, so issuer/audience/key/
// claim types can never drift; BuildValidationParameters pins ValidAlgorithms = HS256 (an
// alg:none or a different-HMAC token is refused). MapInboundClaims=false keeps the minted
// "Role"/"zb:username" claim keys verbatim, which RoleClaimType/NameClaimType name. A bad token
// is AuthenticateResult.Fail ⇒ 401 + WWW-Authenticate: Bearer, never a 500.
authBuilder.AddJwtBearer(OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
services.AddOptions<JwtBearerOptions>(OtOpcUaAuthSchemes.Bearer)
.Configure<JwtTokenService>((o, jwt) =>
{
o.TokenValidationParameters = jwt.BuildValidationParameters();
o.MapInboundClaims = false;
o.SaveToken = false;
o.IncludeErrorDetails = false;
});
}
if (disableLogin) if (disableLogin)
{ {
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered // DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
@@ -142,8 +188,8 @@ public static class ServiceCollectionExtensions
services.AddAuthorization(o => services.AddAuthorization(o =>
{ {
o.FallbackPolicy = new AuthorizationPolicyBuilder( // Named on the selector, not the cookie scheme, so the fallback accepts a bearer token too.
CookieAuthenticationDefaults.AuthenticationScheme) o.FallbackPolicy = new AuthorizationPolicyBuilder(OtOpcUaAuthSchemes.CookieOrBearer)
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.Build(); .Build();
@@ -10,6 +10,10 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
<!-- Direct, to promote JwtBearer's 8.0.1 IdentityModel.Protocols dependencies to the central 8.11.0 pin. -->
<PackageReference Include="Microsoft.IdentityModel.Protocols"/>
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect"/>
<PackageReference Include="Microsoft.IdentityModel.Tokens"/> <PackageReference Include="Microsoft.IdentityModel.Tokens"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/> <PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/> <PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
@@ -0,0 +1,117 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// R03:3.2 on the real provider: the migrated config database allows SNAPSHOT isolation (migration
/// <c>EnableSnapshotIsolation</c>) and <see cref="DraftSnapshotFactory.FromConfigDbAsync"/> reads under
/// it. The InMemory tests cannot see either — the provider has no transactions — so this is the one
/// place the deploy path's "one consistent read" is proven against SQL Server.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class DraftSnapshotFactorySqlServerTests
{
private readonly SchemaComplianceFixture _fixture;
/// <summary>Initializes a new instance of the <see cref="DraftSnapshotFactorySqlServerTests"/> class.</summary>
/// <param name="fixture">The migrated test database.</param>
public DraftSnapshotFactorySqlServerTests(SchemaComplianceFixture fixture) => _fixture = fixture;
/// <summary>The migration leaves the database with snapshot isolation allowed.</summary>
[Fact]
public void Migrated_database_allows_snapshot_isolation()
{
using var conn = _fixture.OpenConnection();
using var cmd = new SqlCommand(
"SELECT snapshot_isolation_state_desc FROM sys.databases WHERE name = DB_NAME();", conn);
cmd.ExecuteScalar().ShouldBe("ON");
}
/// <summary>
/// A row committed by another connection while the snapshot read is in progress is NOT observed by
/// the tables read after it — every table reflects the instant the transaction started. Positive
/// control: a read started after the commit does see the row.
/// </summary>
[Fact]
public async Task FromConfigDb_reads_every_table_as_of_one_instant()
{
var clusterId = $"SNAP-{Guid.NewGuid():N}"[..24];
var scriptId = $"late-{Guid.NewGuid():N}";
await using (var seed = NewContext())
{
seed.ServerClusters.Add(new ServerCluster
{
ClusterId = clusterId, Name = "snapshot-read", Enterprise = "zb", Site = "test",
NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
await seed.SaveChangesAsync();
}
try
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.AddInterceptors(new LateEditInterceptor(_fixture.ConnectionString, clusterId, scriptId))
.Options;
await using (var db = new OtOpcUaConfigDbContext(options))
{
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(db);
// The interceptor committed a Script while ServerClusters (the FIRST table read) was being
// materialised. Scripts is read later in the same transaction and must not see it.
snapshot.ServerClusters.ShouldContain(c => c.ClusterId == clusterId);
snapshot.Scripts.ShouldNotContain(s => s.ScriptId == scriptId);
}
await using (var verify = NewContext())
{
// Positive control: the edit really landed, and a read started afterwards sees it.
(await verify.Scripts.AnyAsync(s => s.ScriptId == scriptId)).ShouldBeTrue();
var later = await DraftSnapshotFactory.FromConfigDbAsync(verify);
later.Scripts.ShouldContain(s => s.ScriptId == scriptId);
}
}
finally
{
await using var cleanup = NewContext();
await cleanup.Scripts.Where(s => s.ScriptId == scriptId).ExecuteDeleteAsync();
await cleanup.ServerClusters.Where(c => c.ClusterId == clusterId).ExecuteDeleteAsync();
}
}
private OtOpcUaConfigDbContext NewContext() => new(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(_fixture.ConnectionString).Options);
/// <summary>
/// Commits a Script on a SEPARATE connection the first time the snapshot read materialises the
/// seeded ServerCluster row — i.e. mid-read, after the first table and before Scripts.
/// </summary>
private sealed class LateEditInterceptor(string connectionString, string clusterId, string scriptId)
: IMaterializationInterceptor
{
private int _fired;
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is ServerCluster c && c.ClusterId == clusterId && Interlocked.Exchange(ref _fired, 1) == 0)
{
using var other = new OtOpcUaConfigDbContext(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(connectionString).Options);
other.Scripts.Add(new Script
{
ScriptId = scriptId, Name = "late edit", SourceCode = "return 1;", SourceHash = "late-hash",
});
other.SaveChanges();
}
return entity;
}
}
}
@@ -103,6 +103,36 @@ public sealed class DraftSnapshotFactoryTests : IDisposable
snapshot.ActiveReservations[0].ReleasedAt.ShouldBeNull(); snapshot.ActiveReservations[0].ReleasedAt.ShouldBeNull();
} }
/// <summary>
/// R03:3.2 — the deploy path validates AND composes from ONE snapshot, so the tables the composer
/// needs beyond the validator's set (clusters, nodes, API keys) must ride the same read.
/// </summary>
[Fact]
public async Task FromConfigDb_carries_clusters_nodes_and_api_keys_for_the_composer()
{
_db.ServerClusters.Add(new ServerCluster
{
ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "site-a",
NodeCount = 1, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
_db.ClusterNodes.Add(new ClusterNode
{
NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
ApplicationUri = "urn:OtOpcUa:site-a-1", Enabled = true, CreatedBy = "test",
});
_db.ApiKeys.Add(new ApiKey
{
ApiKeyId = "k1", Name = "scada", SecretHash = "hash", Principal = "scadabridge", Enabled = true,
});
await _db.SaveChangesAsync();
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
snapshot.ServerClusters.ShouldHaveSingleItem().ClusterId.ShouldBe("SITE-A");
snapshot.ClusterNodes.ShouldHaveSingleItem().NodeId.ShouldBe("site-a-1:4053");
snapshot.ApiKeys.ShouldHaveSingleItem().ApiKeyId.ShouldBe("k1");
}
private void SeedEquipment(string equipmentId) private void SeedEquipment(string equipmentId)
{ {
var uuid = Guid.NewGuid(); var uuid = Guid.NewGuid();
@@ -0,0 +1,159 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:3.1 — the admin singleton's mailbox used to be suspended for the whole of every
/// <c>ReceiveAsync</c>: one Test Connect against a black-holed PLC (60 s clamp) or one large deploy
/// compose stalled every other admin operation fleet-wide, alarm acks included. Both now run off the
/// actor thread; only the "is one in flight" check stays serialised.
/// </summary>
public sealed class AdminOperationsActorMailboxTests : ControlPlaneActorTestBase
{
private static readonly TimeSpan AckBudget = TimeSpan.FromSeconds(1);
/// <summary>An alarm ack completes in under a second while a 60 s Test Connect is still pending.</summary>
[Fact]
public void Alarm_ack_completes_while_a_60s_test_connect_is_pending()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var probe = new BlockingProbe();
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe }));
var connectClient = CreateTestProbe("connect-client");
connectClient.Send(actor, new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 60, Guid.NewGuid()));
probe.Started.Wait(PresenceBudget).ShouldBeTrue("the probe must be in flight before the ack is sent");
actor.Tell(new AcknowledgeAlarmCommand("alarm-1", "op", null, CorrelationId.NewId()));
var ack = ExpectMsg<AcknowledgeAlarmResult>(AckBudget);
ack.Ok.ShouldBeTrue();
// Positive control: the probe reply still lands, with the right sender, once the probe returns.
connectClient.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
probe.Release(new DriverProbeResult(true, null, TimeSpan.FromMilliseconds(5)));
var result = connectClient.ExpectMsg<TestDriverConnectResult>(PresenceBudget);
result.Ok.ShouldBeTrue();
result.LatencyMs.ShouldNotBeNull();
}
/// <summary>A probe that times out still answers its own sender with the clamped timeout message.</summary>
[Fact]
public void Test_connect_timeout_is_reported_to_its_sender()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe("coord");
var probe = new BlockingProbe(); // never released: the CTS fires at the 1 s clamp floor
var actor = Sys.ActorOf(AdminOperationsActor.Props(dbFactory, coordinator.Ref, new IDriverProbe[] { probe }));
actor.Tell(new TestDriverConnect("Blocking", "{}", TimeoutSeconds: 1, Guid.NewGuid()));
var result = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull();
result.Message.ShouldContain("timed out");
}
/// <summary>
/// The deploy compose (snapshot + validate + gzip + row write) runs off the actor: an alarm ack
/// completes while the compose is parked on a gated DB context, and the deploy still completes
/// (Accepted + dispatched) once the gate opens.
/// </summary>
[Fact]
public void Alarm_ack_completes_while_a_deploy_compose_is_in_flight()
{
var gated = new GatedDbFactory(NewInMemoryDbFactory());
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var deployer = CreateTestProbe("deployer");
gated.Hold();
deployer.Send(actor, new StartDeployment("joe", CorrelationId.NewId()));
gated.Entered.Wait(PresenceBudget).ShouldBeTrue("the compose must be parked before the ack is sent");
actor.Tell(new AcknowledgeAlarmCommand("alarm-2", "op", null, CorrelationId.NewId()));
ExpectMsg<AcknowledgeAlarmResult>(AckBudget).Ok.ShouldBeTrue();
// Positive control: the deploy completes once released.
deployer.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
gated.Release();
deployer.ExpectMsg<StartDeploymentResult>(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
}
/// <summary>
/// Only the in-flight check is serialised — and it must still catch a second request that arrives
/// while the first compose has not yet written its row (the DB alone would let both through).
/// </summary>
[Fact]
public void Second_StartDeployment_during_an_in_flight_compose_is_refused()
{
var gated = new GatedDbFactory(NewInMemoryDbFactory());
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(gated, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var first = CreateTestProbe("first");
var second = CreateTestProbe("second");
gated.Hold();
first.Send(actor, new StartDeployment("joe", CorrelationId.NewId()));
gated.Entered.Wait(PresenceBudget).ShouldBeTrue();
second.Send(actor, new StartDeployment("kim", CorrelationId.NewId()));
second.ExpectMsg<StartDeploymentResult>(AckBudget).Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight);
gated.Release();
first.ExpectMsg<StartDeploymentResult>(PresenceBudget).Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
}
private sealed class BlockingProbe : IDriverProbe
{
private readonly TaskCompletionSource<DriverProbeResult> _tcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public ManualResetEventSlim Started { get; } = new();
public string DriverType => "Blocking";
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
Started.Set();
return await _tcs.Task.WaitAsync(ct);
}
public void Release(DriverProbeResult result) => _tcs.TrySetResult(result);
}
/// <summary>
/// Passes synchronous context creation through (the serialised in-flight check) and parks the
/// asynchronous one (the off-actor compose) on a gate while <see cref="Hold"/> is in effect.
/// </summary>
private sealed class GatedDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private volatile TaskCompletionSource? _gate;
public ManualResetEventSlim Entered { get; } = new();
public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
public void Release() => _gate?.TrySetResult();
public OtOpcUaConfigDbContext CreateDbContext() => inner.CreateDbContext();
public async Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
{
Entered.Set();
if (_gate is { } gate) await gate.Task.WaitAsync(cancellationToken);
return inner.CreateDbContext();
}
}
}
@@ -0,0 +1,68 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:4.1 — admin operations echoed <c>ex.Message</c> to the UI, so a SqlException's connection-string
/// fragment reached any ConfigEditor browser session. The reply is now a fixed sentence plus the request's
/// correlation id; the detail stays in the central log.
/// </summary>
public sealed class AdminOperationsActorServerFaultMessageTests : ControlPlaneActorTestBase
{
private const string Secret = "Server=sql-prod-01;Password=hunter2";
[Fact]
public void StartDeployment_server_fault_reply_carries_the_correlation_id_and_not_the_exception_text()
{
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(new ThrowingDbFactory(), coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
var correlationId = CorrelationId.NewId();
actor.Tell(new StartDeployment("joe", correlationId));
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
reply.Message.ShouldNotBeNull();
reply.Message.ShouldNotContain("hunter2");
reply.Message.ShouldNotContain("sql-prod-01");
reply.Message.ShouldContain(correlationId.ToString()); // the operator can find the log line
}
[Fact]
public void TestDriverConnect_probe_throw_reply_carries_the_correlation_id_and_not_the_exception_text()
{
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), coordinator.Ref, new IDriverProbe[] { new ThrowingProbe() }));
var correlationId = Guid.NewGuid();
actor.Tell(new TestDriverConnect("Throwing", "{}", 5, correlationId));
var reply = ExpectMsg<TestDriverConnectResult>(PresenceBudget);
reply.Ok.ShouldBeFalse();
reply.Message.ShouldNotBeNull();
reply.Message.ShouldNotContain("hunter2");
reply.Message.ShouldContain(correlationId.ToString());
}
private sealed class ThrowingDbFactory : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() =>
throw new InvalidOperationException("Cannot open database: " + Secret);
}
private sealed class ThrowingProbe : IDriverProbe
{
public string DriverType => "Throwing";
public Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) =>
throw new InvalidOperationException("probe blew up: " + Secret);
}
}
@@ -0,0 +1,133 @@
using System.IO.Compression;
using System.Text;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Storage;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:3.2 — <c>StartDeployment</c> used to read the whole config DB twice: once for the validator,
/// once for the composer. An edit landing between the two shipped an artifact the validator never saw.
/// The deploy now validates and composes ONE snapshot, so an edit that lands mid-deploy is either
/// validated (it was in the snapshot) or absent from the artifact (it was not) — never shipped unseen.
/// </summary>
public sealed class AdminOperationsActorSingleSnapshotTests : ControlPlaneActorTestBase
{
private const string LateEquipmentId = "EQ-late-and-NOT-canonical";
/// <summary>
/// An Equipment with a non-canonical id (the validator rejects those with <c>EquipmentIdNotDerived</c>)
/// is committed by another context while the snapshot read is in progress, AFTER the Equipment table
/// has been read. The validator cannot see it, so the deploy is Accepted — and the artifact must not
/// carry it either. Positive control: the row really landed in the DB, and a deploy started after it
/// is Rejected on exactly that rule.
/// </summary>
[Fact]
public void Edit_landing_between_validate_and_compose_cannot_ship_unvalidated()
{
// One shared root: an InMemory store is scoped to the internal service provider, and a context
// carrying an interceptor builds its own provider — without the shared root the intercepting
// context would read an EMPTY database and the whole test would be vacuous.
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var plain = new RootedDbFactory(dbName, root, interceptor: null);
using (var db = plain.CreateDbContext())
{
// A Tag to materialise — Tags is read AFTER Equipment by the snapshot factory, so an insert
// fired on the first Tag lands "between" the two tables of one read.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "d", ClusterId = "", Name = "drv", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device { DeviceId = "dev", DriverInstanceId = "d", Name = "dev", DeviceConfig = "{}" });
db.Tags.Add(new Tag
{
TagId = "tag-speed", DeviceId = "dev", Name = "speed", DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
});
db.SaveChanges();
}
var injecting = new RootedDbFactory(dbName, root, new LateEquipmentInterceptor(plain));
var coordinator = CreateTestProbe("coord");
var actor = Sys.ActorOf(AdminOperationsActor.Props(injecting, coordinator.Ref, Enumerable.Empty<IDriverProbe>()));
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var reply = ExpectMsg<StartDeploymentResult>(PresenceBudget);
reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, reply.Message);
coordinator.ExpectMsg<DispatchDeployment>(PresenceBudget);
using (var verify = plain.CreateDbContext())
{
// The injection happened...
verify.Equipment.Any(e => e.EquipmentId == LateEquipmentId).ShouldBeTrue();
// ...and the shipped artifact does not contain what the validator never saw.
var row = verify.Deployments.Single();
Inflate(row.ArtifactBlob).ShouldNotContain(LateEquipmentId);
// The coordinator is a probe here, so seal the row by hand to let the next deploy through.
row.Status = DeploymentStatus.Sealed;
verify.SaveChanges();
}
// Positive control: now that the row is visible to a fresh read, the gate rejects it.
actor.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var second = ExpectMsg<StartDeploymentResult>(PresenceBudget);
second.Outcome.ShouldBe(StartDeploymentOutcome.Rejected);
second.Message.ShouldNotBeNull();
second.Message.ShouldContain("EquipmentIdNotDerived");
}
private static string Inflate(byte[] blob)
{
using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
return reader.ReadToEnd();
}
/// <summary>On the first Tag materialised, commits a non-canonical Equipment through ANOTHER context.</summary>
private sealed class LateEquipmentInterceptor(IDbContextFactory<OtOpcUaConfigDbContext> other)
: IMaterializationInterceptor
{
private int _fired;
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is Tag && Interlocked.Exchange(ref _fired, 1) == 0)
{
using var db = other.CreateDbContext();
db.Equipment.Add(new Equipment
{
EquipmentUuid = Guid.NewGuid(), EquipmentId = LateEquipmentId, Name = "late",
UnsLineId = "line-a", MachineCode = "m",
});
db.SaveChanges();
}
return entity;
}
}
private sealed class RootedDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor? interceptor)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext()
{
var builder = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName, root);
if (interceptor is not null) builder.AddInterceptors(interceptor);
return new OtOpcUaConfigDbContext(builder.Options);
}
}
}
@@ -0,0 +1,191 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Update;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:2.6 — one constraint hit used to drop the whole flush. The writer now detaches the rejected
/// row(s) and re-saves, so a duplicate <c>EventId</c> costs exactly that row.
/// </summary>
public sealed class AuditWriterActorRejectedRowTests : ControlPlaneActorTestBase
{
[Fact]
public void A_rejected_row_is_dropped_and_the_rest_of_the_batch_is_persisted()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var duplicate = Guid.NewGuid();
var rejecting = new RejectingDbFactory(dbName, root, new AlwaysRejectBatchContaining(duplicate));
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance); // PostStop flushes
ExpectTerminated(actor);
using var db = rejecting.CreateDbContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(20, "every row but the rejected one lands");
persisted.ShouldNotContain(duplicate);
// Positive control: the rows on either side of the rejected one are present.
persisted.ShouldContain(ids[6]);
persisted.ShouldContain(ids[8]);
}
/// <summary>
/// Fix round 1 / I4 — a FAITHFUL SQL Server shape: the provider sends the batch as one statement batch,
/// so the <see cref="DbUpdateException"/> for one duplicate names EVERY entry in it. Detaching
/// <c>ex.Entries</c> therefore dropped the whole flush. The writer must drop only the rows whose
/// EventId is already persisted and keep the rest.
/// </summary>
[Fact]
public void A_whole_batch_rejection_drops_only_the_rows_already_persisted()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var factory = new RejectingDbFactory(dbName, root, new RejectWholeBatchOnDuplicate());
var duplicate = Guid.NewGuid();
using (var seed = factory.CreateDbContext())
{
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
seed.SaveChanges();
}
var actor = Sys.ActorOf(AuditWriterActor.Props(factory));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor);
using var db = factory.CreateDbContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(21, "the 20 new rows land beside the one that was already there");
persisted.Count(id => id == duplicate).ShouldBe(1);
db.ConfigAuditLogs.Single(r => r.EventId == duplicate).Principal.ShouldBe("earlier");
}
/// <summary>The same contract against the REAL provider and the real filtered unique index.</summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public void On_sql_server_a_duplicate_event_id_costs_only_that_row()
{
using var sql = new SqlServerTestDatabase();
var duplicate = Guid.NewGuid();
using (var seed = sql.NewContext())
{
seed.ConfigAuditLogs.Add(new ConfigAuditLog { Principal = "earlier", EventType = "Config:Edit", EventId = duplicate });
seed.SaveChanges();
}
var actor = Sys.ActorOf(AuditWriterActor.Props(sql.Factory()));
var ids = Enumerable.Range(0, 20).Select(_ => Guid.NewGuid()).ToList();
ids.Insert(7, duplicate);
foreach (var id in ids) actor.Tell(NewEvent(id));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor, PresenceBudget);
using var db = sql.NewContext();
var persisted = db.ConfigAuditLogs.Select(r => r.EventId).ToList();
persisted.Count.ShouldBe(21);
persisted.Count(id => id == duplicate).ShouldBe(1);
foreach (var id in ids) persisted.ShouldContain(id);
}
/// <summary>A failure that names no entry is a whole-batch failure, exactly as before.</summary>
[Fact]
public void A_connection_level_failure_still_drops_the_batch_without_looping()
{
var dbName = Guid.NewGuid().ToString("N");
var root = new InMemoryDatabaseRoot();
var rejecting = new RejectingDbFactory(dbName, root, new AlwaysFailWithoutEntries());
var actor = Sys.ActorOf(AuditWriterActor.Props(rejecting));
for (var i = 0; i < 5; i++) actor.Tell(NewEvent(Guid.NewGuid()));
Watch(actor);
actor.Tell(PoisonPill.Instance);
ExpectTerminated(actor); // terminates ⇒ the retry loop did not spin
using var db = rejecting.CreateDbContext();
db.ConfigAuditLogs.Count().ShouldBe(0);
}
private static AuditEvent NewEvent(Guid eventId) => new()
{
EventId = eventId, Category = "Config", Action = "Edit", Actor = "joe",
OccurredAtUtc = DateTimeOffset.UtcNow, Outcome = AuditOutcome.Success,
DetailsJson = "{}", SourceNode = "node-a", CorrelationId = Guid.NewGuid(),
};
/// <summary>
/// A row the database refuses on its own terms (not a duplicate — e.g. a per-row constraint), every
/// time it is in the save. Faithful to SQL Server's batch shape: the exception names EVERY added
/// entry, so only a row-by-row retry can isolate the offender.
/// </summary>
private sealed class AlwaysRejectBatchContaining(Guid eventId) : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (eventData.Context is null) return result;
var added = eventData.Context.ChangeTracker.Entries<ConfigAuditLog>()
.Where(e => e.State == EntityState.Added).ToList();
if (!added.Any(e => e.Entity.EventId == eventId)) return result;
#pragma warning disable EF1001
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
#pragma warning restore EF1001
throw new DbUpdateException("The statement conflicted with a CHECK constraint (simulated, whole batch)", entries);
}
}
/// <summary>
/// Models SQL Server's batched insert: when ANY added row's EventId is already stored, the whole save
/// fails with an exception naming EVERY added entry (the InMemory provider enforces no unique index,
/// so this interceptor is the constraint).
/// </summary>
private sealed class RejectWholeBatchOnDuplicate : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
if (eventData.Context is not OtOpcUaConfigDbContext ctx) return result;
var added = ctx.ChangeTracker.Entries<ConfigAuditLog>().Where(e => e.State == EntityState.Added).ToList();
var ids = added.Select(e => e.Entity.EventId).Where(id => id != null).ToList();
if (ids.Count == 0 || !ctx.ConfigAuditLogs.AsNoTracking().Any(r => ids.Contains(r.EventId))) return result;
#pragma warning disable EF1001
var entries = added.Select(e => (IUpdateEntry)e.GetInfrastructure()).ToList();
#pragma warning restore EF1001
throw new DbUpdateException("Cannot insert duplicate key row (simulated 2601, whole batch)", entries);
}
}
private sealed class AlwaysFailWithoutEntries : ISaveChangesInterceptor
{
public InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) =>
throw new DbUpdateException("connection lost (simulated)");
}
private sealed class RejectingDbFactory(string dbName, InMemoryDatabaseRoot root, IInterceptor interceptor)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => new(
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(dbName, root)
.AddInterceptors(interceptor)
.Options);
}
}
@@ -930,6 +930,34 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
/// <summary>A spawned gRPC-mode actor plus the two seams the tests observe it through.</summary> /// <summary>A spawned gRPC-mode actor plus the two seams the tests observe it through.</summary>
private sealed record GrpcRig(IActorRef Actor, RecordingDialer Dialer, RecordingPskProvider Psk); private sealed record GrpcRig(IActorRef Actor, RecordingDialer Dialer, RecordingPskProvider Psk);
/// <summary>
/// R03:1.3 — every contact refresh hands the dialer the CURRENT endpoint set so it can release the
/// channels of endpoints that left it.
/// </summary>
[Fact]
public void Each_contact_refresh_tells_the_dialer_which_endpoints_remain()
{
var factory = NewInMemoryDbFactory();
SeedGrpcCluster(factory, "C1", ("host-a:4053", "host-a", 4056, true, false), ("host-b:4053", "host-b", 4056, true, false));
var rig = SpawnGrpcRig(factory);
AwaitContactsLoaded(rig);
// The dialer is resolved lazily on first use; a dispatch forces that, and the NEXT refresh prunes.
rig.Actor.Tell(new MeshCommand(DeploymentsTopic, NewDispatch()));
AwaitCondition(() => rig.Dialer.Calls.Count == 2, TimeSpan.FromSeconds(5));
using (var db = factory.CreateDbContext())
{
db.ClusterNodes.Single(n => n.NodeId == "host-b:4053").MaintenanceMode = true;
db.SaveChanges();
}
rig.Actor.Tell(new CentralCommunicationActor.RefreshContacts());
AwaitCondition(
() => rig.Dialer.RetainCalls.Any(set => set.Count == 1 && set.Single().Contains("host-a")),
TimeSpan.FromSeconds(5));
rig.Dialer.RetainCalls.Last().ShouldBe(new[] { "http://host-a:4056" });
}
private GrpcRig SpawnGrpcRig( private GrpcRig SpawnGrpcRig(
IDbContextFactory<OtOpcUaConfigDbContext> factory, IDbContextFactory<OtOpcUaConfigDbContext> factory,
RecordingDialer? dialer = null, RecordingDialer? dialer = null,
@@ -1014,6 +1042,19 @@ public class CentralCommunicationActorTests : ControlPlaneActorTestBase
: Task.CompletedTask; : Task.CompletedTask;
} }
/// <summary>Every endpoint set handed to <see cref="RetainOnly"/>, in order.</summary>
public IReadOnlyList<IReadOnlyCollection<string>> RetainCalls
{
get { lock (_gate) return _retainCalls.ToList(); }
}
private readonly List<IReadOnlyCollection<string>> _retainCalls = [];
public void RetainOnly(IReadOnlyCollection<string> nodeEndpoints)
{
lock (_gate) _retainCalls.Add(nodeEndpoints.ToList());
}
/// <summary>One recorded dial.</summary> /// <summary>One recorded dial.</summary>
public sealed record Dial(string Endpoint, CommandEnvelope Envelope, string ClusterId); public sealed record Dial(string Endpoint, CommandEnvelope Envelope, string ClusterId);
} }
@@ -224,6 +224,35 @@ public sealed class MeshGrpcCommandDialerTests
Should.NotThrow(dialer.Dispose); Should.NotThrow(dialer.Dispose);
} }
/// <summary>
/// R03:1.3 — a channel for an endpoint that left the dispatch set (host renamed, port moved) is
/// released on the next refresh instead of living, with its idle connection, for the life of central.
/// Positive control: a dial to that endpoint afterwards rebuilds a channel.
/// </summary>
[Fact]
public async Task RetainOnly_releases_channels_for_endpoints_that_left_the_dispatch_set()
{
var dialer = new MeshGrpcCommandDialer(
new FakePskProvider(("SITE-A", "psk-site-a")),
Options.Create(new MeshTransportOptions { DispatchTimeoutSeconds = 1 }),
NullLogger<MeshGrpcCommandDialer>.Instance);
foreach (var endpoint in new[] { "http://127.0.0.1:1", "http://127.0.0.1:2" })
{
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync(endpoint, Envelope(), "SITE-A", CancellationToken.None));
}
dialer.CachedChannelCount.ShouldBe(2);
dialer.RetainOnly(new[] { "http://127.0.0.1:1" });
dialer.CachedChannelCount.ShouldBe(1);
await Should.ThrowAsync<RpcException>(
() => dialer.DispatchAsync("http://127.0.0.1:2", Envelope(), "SITE-A", CancellationToken.None));
dialer.CachedChannelCount.ShouldBe(2, "a returning endpoint gets a fresh channel");
Should.NotThrow(dialer.Dispose);
}
[Fact] [Fact]
public void It_activates_from_the_container_despite_the_optional_client_factory_parameter() public void It_activates_from_the_container_despite_the_optional_client_factory_parameter()
{ {
@@ -0,0 +1,200 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Communication;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Communication;
/// <summary>
/// R03:3.4 — central invalidated every cached key on every contact refresh (default 60 s), so the
/// first inbound ack per cluster per minute blocked a gRPC thread on a synchronous, 3 s-bounded store
/// read and was denied past the bound. <see cref="MeshPskProvider.RefreshAsync"/> pre-resolves the
/// keys off the request path and swaps them in atomically.
/// </summary>
public sealed class MeshPskProviderRefreshTests
{
private static MeshPskProvider Provider(ISecretResolver resolver) =>
new(Options.Create(new MeshTransportOptions()), resolver, NullLogger<MeshPskProvider>.Instance);
[Fact]
public async Task After_a_refresh_the_lookup_is_served_without_touching_the_store()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Calls.ShouldBe(1);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-1");
resolver.Calls.ShouldBe(1, "the request path did not consult the store");
provider.Matches("SITE-A", "k-1").ShouldBeTrue();
}
/// <summary>
/// While a refresh is in flight the PREVIOUS key is still served (no invalidate-then-reread window);
/// once it completes the rotated key is served. Positive control on both sides of the swap.
/// </summary>
[Fact]
public async Task A_rotated_key_is_swapped_in_atomically_with_the_old_key_served_until_then()
{
var resolver = new GatedResolver { Value = "old" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out var before).ShouldBeTrue();
before.ShouldBe("old");
resolver.Value = "new";
resolver.Hold();
var refresh = provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Entered.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue();
// Mid-refresh: the old key, from memory — the store is NOT re-read on this path.
var callsMidRefresh = resolver.Calls;
provider.TryGetPsk("SITE-A", out var during).ShouldBeTrue();
during.ShouldBe("old");
resolver.Calls.ShouldBe(callsMidRefresh);
resolver.Release();
await refresh;
provider.TryGetPsk("SITE-A", out var after).ShouldBeTrue();
after.ShouldBe("new");
}
[Fact]
public async Task A_cluster_whose_refresh_fails_keeps_its_previous_key()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
resolver.Throw = new InvalidOperationException("store offline");
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-1");
}
/// <summary>
/// Fix round 1 / I8 — "the store says there is no such secret" is a REVOCATION, not a read failure:
/// the previous key must stop being served (from the refreshed snapshot AND the lazy cache). A thrown
/// read keeps the previous key (the test above); a definitive not-found drops it.
/// </summary>
[Fact]
public async Task A_secret_deleted_from_the_store_revokes_the_key_on_the_next_refresh()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.Matches("SITE-A", "k-1").ShouldBeTrue("positive control: the key was in service");
resolver.Value = null; // the secret was deleted
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
provider.Matches("SITE-A", "k-1").ShouldBeFalse();
}
/// <summary>The same revocation reaches a key that was first served from the LAZY cache, not a refresh.</summary>
[Fact]
public async Task A_deleted_secret_also_evicts_a_lazily_cached_key()
{
var resolver = new GatedResolver { Value = "k-lazy" };
var provider = Provider(resolver);
provider.TryGetPsk("SITE-A", out var lazy).ShouldBeTrue();
lazy.ShouldBe("k-lazy");
resolver.Value = null;
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-A", out _).ShouldBeFalse();
}
/// <summary>Fix round 1 minor — the refresh fans out to the store with BOUNDED concurrency, not one read per cluster at once.</summary>
[Fact]
public async Task Refresh_store_reads_are_concurrency_bounded()
{
var resolver = new ConcurrencyTrackingResolver();
var provider = Provider(resolver);
var clusters = Enumerable.Range(0, 40).Select(i => $"SITE-{i}").ToList();
await provider.RefreshAsync(clusters, CancellationToken.None);
resolver.Calls.ShouldBe(40);
resolver.MaxConcurrent.ShouldBeLessThanOrEqualTo(MeshPskProvider.MaxConcurrentRefreshReads);
resolver.MaxConcurrent.ShouldBeGreaterThan(1, "still concurrent, just bounded");
foreach (var c in clusters) provider.TryGetPsk(c, out _).ShouldBeTrue();
}
private sealed class ConcurrencyTrackingResolver : ISecretResolver
{
private int _inFlight;
private int _max;
private int _calls;
public int MaxConcurrent => Volatile.Read(ref _max);
public int Calls => Volatile.Read(ref _calls);
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
var now = Interlocked.Increment(ref _inFlight);
int seen;
while (now > (seen = Volatile.Read(ref _max)) && Interlocked.CompareExchange(ref _max, now, seen) != seen) { }
await Task.Delay(20, ct);
Interlocked.Decrement(ref _inFlight);
return "k-" + name.Value;
}
}
/// <summary>A cluster that was never refreshed still resolves lazily on the request path (unchanged behaviour).</summary>
[Fact]
public async Task A_cluster_outside_the_refresh_set_still_resolves_lazily()
{
var resolver = new GatedResolver { Value = "k-b" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.TryGetPsk("SITE-B", out var psk).ShouldBeTrue();
psk.ShouldBe("k-b");
resolver.Calls.ShouldBe(2);
}
[Fact]
public async Task InvalidateAll_drops_refreshed_keys_too()
{
var resolver = new GatedResolver { Value = "k-1" };
var provider = Provider(resolver);
await provider.RefreshAsync(new[] { "SITE-A" }, CancellationToken.None);
provider.InvalidateAll();
resolver.Value = "k-2";
provider.TryGetPsk("SITE-A", out var psk).ShouldBeTrue();
psk.ShouldBe("k-2");
}
private sealed class GatedResolver : ISecretResolver
{
private int _calls;
private volatile TaskCompletionSource? _gate;
public string? Value { get; set; }
public Exception? Throw { get; set; }
public int Calls => Volatile.Read(ref _calls);
public ManualResetEventSlim Entered { get; } = new();
public void Hold() => _gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
public void Release() => _gate?.TrySetResult();
public async Task<string?> GetAsync(SecretName name, CancellationToken ct)
{
Interlocked.Increment(ref _calls);
Entered.Set();
if (_gate is { } gate) await gate.Task.WaitAsync(ct);
if (Throw is not null) throw Throw;
return Value;
}
}
}
@@ -0,0 +1,87 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Fix round 1 minor — revision-hash stability across the R03:3.2 refactor. The pre-change composer read
/// every table with a SQL <c>ORDER BY</c> (the database's case-INSENSITIVE collation); the refactor re-sorted
/// in memory with an ORDINAL comparer, which orders mixed-case keys differently ("B-1" before "a-2"). The
/// same configuration would then hash differently after the upgrade: drift detection reports every node
/// drifted, and a re-deploy of an unchanged config shows a "new" revision. This pins the composer's output
/// BYTE-identical to a verbatim replica of the pre-change composer on the real provider.
/// </summary>
[Trait("Category", "SchemaCompliance")]
public sealed class ConfigComposerSqlServerHashStabilityTests
{
[Fact]
public async Task Composer_output_is_byte_identical_to_the_pre_change_composer_for_mixed_case_keys()
{
using var sql = new SqlServerTestDatabase();
await using (var seed = sql.NewContext())
{
// Collation order a-2, B-1, c-3; ordinal order B-1, a-2, c-3.
foreach (var id in new[] { "c-3", "B-1", "a-2" })
seed.Scripts.Add(new Script { ScriptId = id, Name = id, SourceCode = "return 1;", SourceHash = id });
await seed.SaveChangesAsync();
}
await using var legacyDb = sql.NewContext();
var legacy = await LegacySnapshotAndFlattenAsync(legacyDb);
await using var viaDb = sql.NewContext();
var current = await ConfigComposer.SnapshotAndFlattenAsync(viaDb);
current.Blob.ShouldBe(legacy.Blob);
current.RevisionHash.ShouldBe(legacy.RevisionHash);
// The deploy path's form (validate + compose from ONE snapshot) must agree too.
await using var deployDb = sql.NewContext();
var viaSnapshot = ConfigComposer.Compose(await DraftSnapshotFactory.FromConfigDbAsync(deployDb));
viaSnapshot.RevisionHash.ShouldBe(legacy.RevisionHash);
}
/// <summary>Verbatim replica of <c>ConfigComposer.SnapshotAndFlattenAsync</c> before R03:3.2 (ad2b5c40).</summary>
private static async Task<(byte[] Blob, string RevisionHash)> LegacySnapshotAndFlattenAsync(OtOpcUaConfigDbContext db)
{
var ct = CancellationToken.None;
var snapshot = new
{
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
};
var json = JsonSerializer.SerializeToUtf8Bytes(snapshot, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
});
using var output = new MemoryStream(json.Length / 8);
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
gzip.Write(json, 0, json.Length);
var blob = output.ToArray();
return (blob, Convert.ToHexStringLower(SHA256.HashData(blob)));
}
}
@@ -222,6 +222,47 @@ public sealed class ConfigComposerTests : ControlPlaneActorTestBase
withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn"); withoutOverride.ResilienceConfig.ShouldBeNull("an unset override stays null ⇒ tier defaults at spawn");
} }
/// <summary>
/// R03:3.2 — the deploy path composes from the <see cref="Configuration.Validation.DraftSnapshot"/> it
/// validated rather than re-reading the DB. <see cref="ConfigComposer.Compose"/> over the factory's
/// snapshot must therefore be byte-identical to the DB overload, or the drift probe (which still
/// uses the DB overload) would report every deployment as drifted.
/// </summary>
[Fact]
public async Task Compose_over_the_factory_snapshot_is_byte_identical_to_the_db_overload()
{
var f = NewInMemoryDbFactory();
await using (var db = f.CreateDbContext())
{
db.ServerClusters.Add(NewCluster("cluster-b"));
db.ServerClusters.Add(NewCluster("cluster-a"));
db.ApiKeys.Add(new ApiKey
{
ApiKeyId = "k1", Name = "scada", SecretHash = "h", Principal = "scadabridge", CreatedUtc = FixedTimestamp,
});
db.Scripts.Add(new Script { ScriptId = "s-1", Name = "s", SourceCode = "return 1;", SourceHash = "x" });
await db.SaveChangesAsync();
}
var viaDb = await ConfigComposer.SnapshotAndFlattenAsync(f.CreateDbContext());
var snapshot = await Configuration.Validation.DraftSnapshotFactory.FromConfigDbAsync(f.CreateDbContext());
var viaSnapshot = ConfigComposer.Compose(snapshot);
viaSnapshot.RevisionHash.ShouldBe(viaDb.RevisionHash);
viaSnapshot.Blob.ShouldBe(viaDb.Blob);
// The composed content is the snapshot's, not something re-read: the keys and clusters are in it.
var json = Inflate(viaSnapshot.Blob);
json.ShouldContain("\"ApiKeyId\":\"k1\"");
json.IndexOf("cluster-a", StringComparison.Ordinal).ShouldBeLessThan(json.IndexOf("cluster-b", StringComparison.Ordinal));
}
private static string Inflate(byte[] blob)
{
using var gz = new GZipStream(new MemoryStream(blob), CompressionMode.Decompress);
using var reader = new StreamReader(gz, Encoding.UTF8);
return reader.ReadToEnd();
}
private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); private static readonly DateTime FixedTimestamp = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static ServerCluster NewCluster(string id) => new() private static ServerCluster NewCluster(string id) => new()
@@ -0,0 +1,440 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Review 03 §2.2: a <c>Deployment</c> row left in <c>Dispatching</c> with NO <c>NodeDeploymentState</c>
/// rows — <c>HandleDispatch</c> threw before its <c>SaveChanges</c>, or the process died between the
/// AdminOps row write and the coordinator's <c>Tell</c> — used to be recovered with an EMPTY expected
/// set, at which point the deadline hit the <c>0 == 0</c> "race" branch and returned without touching
/// the row. Every later <c>StartDeployment</c> was then refused <c>AnotherDeploymentInFlight</c> forever.
/// </summary>
public sealed class ConfigPublishCoordinatorRecoveryTests : ControlPlaneActorTestBase
{
private static readonly RevisionHash TestRevision = RevisionHash.Parse(new string('c', 64));
private static readonly TimeSpan NoTimeout = TimeSpan.FromMinutes(5);
/// <summary>
/// A lost dispatch that is still inside the apply deadline is re-driven: PreStart re-dispatches it,
/// the expected set is seeded from the enabled rows, and the node's ack seals it.
/// </summary>
[Fact]
public void Recovered_row_with_no_expected_set_is_redispatched_and_then_seals()
{
var dbFactory = NewInMemoryDbFactory();
var deploymentId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
SeedNodes(dbFactory, "site-a-1:4053");
// No DispatchDeployment is ever sent — PreStart alone has to recover it.
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" });
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>
/// A lost dispatch that is already past the apply deadline is closed as <c>TimedOut</c> with a reason
/// naming the condition — never left in <c>Dispatching</c> — and the next deployment proceeds
/// (positive control: it is dispatched, acked and sealed through the same coordinator).
/// </summary>
[Fact]
public void Recovered_row_past_deadline_with_no_expected_set_is_timed_out_not_wedged()
{
var dbFactory = NewInMemoryDbFactory();
var staleId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddMinutes(-10));
SeedNodes(dbFactory, "site-a-1:4053");
var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
var row = db.Deployments.Single(d => d.DeploymentId == staleId.Value);
row.Status.ShouldBe(DeploymentStatus.TimedOut);
row.FailureReason.ShouldNotBeNull();
row.FailureReason.ShouldContain("lost");
}, duration: PresenceBudget);
// Positive control: the coordinator is not wedged — a fresh deployment runs end to end.
var nextId = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
actor.Tell(new DispatchDeployment(nextId, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == nextId.Value).Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
actor.Tell(new ApplyAck(nextId, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == nextId.Value).Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>
/// An <c>AwaitingApplyAcks</c> row with no rows (the process died between the status flip and the
/// empty-fleet seal) is re-driven too, and with no enabled nodes it seals empty exactly as a fresh
/// dispatch would — the recovery keys on the missing expected set, not on the status value.
/// </summary>
[Fact]
public void Recovered_AwaitingApplyAcks_row_with_no_rows_and_no_fleet_seals_empty()
{
var dbFactory = NewInMemoryDbFactory();
SeedDeployment(dbFactory, DeploymentStatus.AwaitingApplyAcks, DateTime.UtcNow);
Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
db.NodeDeploymentStates.ShouldBeEmpty();
}, duration: PresenceBudget);
}
/// <summary>
/// The brief's scenario end to end: <c>HandleDispatch</c> throws once (a transient fault seeding the
/// expected set). The coordinator restarts, recovers the <c>Dispatching</c> row, re-dispatches it and
/// the deployment completes; a second <c>StartDeployment</c> is then Accepted (positive control: a
/// normal deploy seals).
/// </summary>
[Fact]
public async Task HandleDispatch_throw_is_recovered_and_the_next_StartDeployment_succeeds()
{
var dbName = Guid.NewGuid().ToString("N");
var adminFactory = NewInMemoryDbFactory(dbName);
var faulting = new OneShotFaultingDbFactory(NewInMemoryDbFactory(dbName));
SeedNodes(adminFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(faulting, NoTimeout));
var admin = Sys.ActorOf(AdminOperationsActor.Props(adminFactory, coordinator, Enumerable.Empty<IDriverProbe>()));
// Arm the fault AFTER PreStart has run (it would otherwise fault the recovery read itself and
// the actor would be stopped, not restarted). Identify is processed after creation, so a
// resolved selection proves PreStart completed.
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget);
faulting.FailNext();
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var first = ExpectMsg<StartDeploymentResult>(PresenceBudget);
first.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
first.DeploymentId.ShouldNotBeNull();
// The fault fired, the actor restarted, PreStart re-dispatched: the expected set lands.
AwaitAssert(() =>
{
faulting.Faulted.ShouldBeTrue();
using var db = adminFactory.CreateDbContext();
db.NodeDeploymentStates.Count(s => s.DeploymentId == first.DeploymentId.Value.Value).ShouldBe(1);
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
coordinator.Tell(new ApplyAck(first.DeploymentId.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = adminFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
// Positive control: nothing is wedged — the next deploy is Accepted and seals too.
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var second = ExpectMsg<StartDeploymentResult>(PresenceBudget);
second.Outcome.ShouldBe(StartDeploymentOutcome.Accepted);
coordinator.Tell(new ApplyAck(second.DeploymentId!.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = adminFactory.CreateDbContext();
db.Deployments.Count(d => d.Status == DeploymentStatus.Sealed).ShouldBe(2);
}, duration: PresenceBudget);
}
/// <summary>
/// Fix round 1 / I7 — the window PreStart cannot see: the Dispatching row is committed AFTER the
/// coordinator's PreStart ran, and the coordinator is never told (the admin singleton died between
/// its commit and the Tell). Nothing would ever close it. The in-flight check now closes a
/// Dispatching row with NO expected-ack rows once it is past the apply deadline, so the next
/// StartDeployment is Accepted (and seals — positive control on the pipeline).
/// </summary>
[Fact]
public async Task Lost_dispatch_committed_after_PreStart_is_closed_by_the_next_StartDeployment()
{
var dbFactory = NewInMemoryDbFactory();
SeedNodes(dbFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget); // PreStart saw nothing
var stale = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddMinutes(-10));
var admin = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator, Enumerable.Empty<IDriverProbe>(), applyDeadline: NoTimeout));
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var result = ExpectMsg<StartDeploymentResult>(PresenceBudget);
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, result.Message);
using (var db = dbFactory.CreateDbContext())
{
var row = db.Deployments.Single(d => d.DeploymentId == stale.Value);
row.Status.ShouldBe(DeploymentStatus.TimedOut);
row.FailureReason.ShouldNotBeNull();
row.FailureReason.ShouldContain("lost");
}
coordinator.Tell(new ApplyAck(result.DeploymentId!.Value, NodeId.Parse("site-a-1:4053"),
ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == result.DeploymentId.Value.Value).Status
.ShouldBe(DeploymentStatus.Sealed);
}, duration: PresenceBudget);
}
/// <summary>Control for the closure: the same row INSIDE the deadline is still in flight and still refuses.</summary>
[Fact]
public void Dispatching_row_inside_the_deadline_still_refuses_a_new_deployment()
{
var dbFactory = NewInMemoryDbFactory();
var coordinator = CreateTestProbe();
var pending = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow.AddSeconds(-5));
var admin = Sys.ActorOf(AdminOperationsActor.Props(
dbFactory, coordinator.Ref, Enumerable.Empty<IDriverProbe>(), applyDeadline: NoTimeout));
admin.Tell(new StartDeployment("joe", CorrelationId.NewId()));
var result = ExpectMsg<StartDeploymentResult>(PresenceBudget);
result.Outcome.ShouldBe(StartDeploymentOutcome.AnotherDeploymentInFlight);
result.DeploymentId.ShouldBe(pending);
using var db = dbFactory.CreateDbContext();
db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Dispatching);
}
/// <summary>
/// Fix round 1 minor — a late or duplicated <c>DispatchDeployment</c> for a row that is already
/// terminal (it raced the lost-dispatch closure above, or a re-delivery after a seal) must not flip
/// it back to AwaitingApplyAcks or seed an expected set. Positive control: a live row dispatched right
/// after it IS seeded (so the first message was processed, not merely not-yet-processed).
/// </summary>
[Theory]
[InlineData(DeploymentStatus.Sealed)]
[InlineData(DeploymentStatus.TimedOut)]
[InlineData(DeploymentStatus.PartiallyFailed)]
public void Dispatch_of_a_terminal_row_is_ignored(DeploymentStatus terminal)
{
var dbFactory = NewInMemoryDbFactory();
SeedNodes(dbFactory, "site-a-1:4053");
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout));
var done = SeedDeployment(dbFactory, terminal, DateTime.UtcNow.AddMinutes(-1));
coordinator.Tell(new DispatchDeployment(done, TestRevision, CorrelationId.NewId()));
var live = SeedDeployment(dbFactory, DeploymentStatus.Dispatching, DateTime.UtcNow);
coordinator.Tell(new DispatchDeployment(live, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = dbFactory.CreateDbContext();
db.Deployments.Single(d => d.DeploymentId == live.Value).Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
using var verify = dbFactory.CreateDbContext();
verify.Deployments.Single(d => d.DeploymentId == done.Value).Status.ShouldBe(terminal);
verify.NodeDeploymentStates.Count(s => s.DeploymentId == done.Value).ShouldBe(0);
}
/// <summary>
/// Fix round 1 minor — status transitions update a blob-free stub (key + RowVersion) instead of
/// materialising the multi-MB row. Proven on the REAL provider, where the stub's RowVersion is the
/// optimistic-concurrency predicate: dispatch → ack → Sealed lands, and the artifact bytes are
/// untouched by the partial UPDATEs.
/// </summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public void On_sql_server_status_transitions_do_not_touch_the_artifact_blob()
{
using var sql = new SqlServerTestDatabase();
var factory = sql.Factory();
SeedNodes(factory, "site-a-1:4053");
var blob = Enumerable.Range(0, 4096).Select(i => (byte)i).ToArray();
var id = DeploymentId.NewId();
using (var db = sql.NewContext())
{
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value, RevisionHash = TestRevision.Value, Status = DeploymentStatus.Dispatching,
CreatedBy = "test", ArtifactBlob = blob,
});
db.SaveChanges();
}
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(factory, NoTimeout));
AwaitAssert(() =>
{
using var db = sql.NewContext();
db.Deployments.AsNoTracking().Single().Status.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget); // PreStart re-drives the un-dispatched row
coordinator.Tell(new ApplyAck(id, NodeId.Parse("site-a-1:4053"), ApplyAckOutcome.Applied, null, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = sql.NewContext();
var row = db.Deployments.AsNoTracking().Single();
row.Status.ShouldBe(DeploymentStatus.Sealed);
row.SealedAtUtc.ShouldNotBeNull();
row.ArtifactBlob.ShouldBe(blob);
}, duration: PresenceBudget);
}
/// <summary>
/// Re-review 1 — the reopen race. AdminOps' lost-dispatch closure commits TimedOut AFTER the coordinator's
/// terminal guard read the row as Dispatching but BEFORE the AwaitingApplyAcks flip. The flip is
/// conditioned on the guard's RowVersion, so on SQL Server it becomes a concurrency conflict and the row
/// stays TimedOut with no expected set. Positive control: the coordinator is not wedged — the next live
/// dispatch is seeded and flipped.
/// </summary>
[Fact]
[Trait("Category", "SchemaCompliance")]
public async Task On_sql_server_a_closure_between_guard_and_flip_is_not_reopened()
{
using var sql = new SqlServerTestDatabase();
var inner = sql.Factory();
SeedNodes(inner, "site-a-1:4053");
var interposer = new InterposingDbFactory(inner);
var coordinator = Sys.ActorOf(ConfigPublishCoordinator.Props(interposer, NoTimeout));
await Sys.ActorSelection(coordinator.Path).ResolveOne(PresenceBudget); // PreStart ran over an empty table
// The race proper: a new Dispatching row whose dispatch is interleaved with an AdminOps-style closure.
var victim = SeedDeployment(inner, DeploymentStatus.Dispatching, DateTime.UtcNow);
interposer.OnSecondCreate(() =>
{
using var db = sql.NewContext();
db.Database.ExecuteSqlRaw(
"UPDATE Deployment SET Status = {0}, FailureReason = N'closed as lost' WHERE DeploymentId = {1}",
(int)DeploymentStatus.TimedOut, victim.Value);
});
coordinator.Tell(new DispatchDeployment(victim, TestRevision, CorrelationId.NewId()));
AwaitAssert(() => interposer.Fired.ShouldBeTrue(), duration: PresenceBudget);
// Positive control + ordering: a live row dispatched after it IS flipped, so the victim's dispatch was processed.
var live = SeedDeployment(inner, DeploymentStatus.Dispatching, DateTime.UtcNow);
coordinator.Tell(new DispatchDeployment(live, TestRevision, CorrelationId.NewId()));
AwaitAssert(() =>
{
using var db = sql.NewContext();
db.Deployments.AsNoTracking().Single(d => d.DeploymentId == live.Value).Status
.ShouldBe(DeploymentStatus.AwaitingApplyAcks);
}, duration: PresenceBudget);
using var verify = sql.NewContext();
verify.Deployments.AsNoTracking().Single(d => d.DeploymentId == victim.Value).Status
.ShouldBe(DeploymentStatus.TimedOut, "the closure must not be reopened");
verify.NodeDeploymentStates.Count(s => s.DeploymentId == victim.Value).ShouldBe(0);
}
/// <summary>Runs an action just before handing out the SECOND context after arming (HandleDispatch: guard, then flip).</summary>
private sealed class InterposingDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private Action? _action;
private int _countdown;
public volatile bool Fired;
public void OnSecondCreate(Action action)
{
_action = action;
Volatile.Write(ref _countdown, 2);
}
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Volatile.Read(ref _countdown) > 0 && Interlocked.Decrement(ref _countdown) == 0)
{
_action?.Invoke();
Fired = true;
}
return inner.CreateDbContext();
}
}
private static DeploymentId SeedDeployment(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, DeploymentStatus status, DateTime createdAtUtc)
{
var id = DeploymentId.NewId();
using var db = dbFactory.CreateDbContext();
db.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = TestRevision.Value,
Status = status,
CreatedBy = "test",
CreatedAtUtc = createdAtUtc,
});
db.SaveChanges();
return id;
}
private static void SeedNodes(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory, params string[] nodeIds)
{
using var db = dbFactory.CreateDbContext();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = "SITE-A", Name = "Site A", Enterprise = "zb", Site = "site-a",
NodeCount = (byte)nodeIds.Length, RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
foreach (var nodeId in nodeIds)
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = nodeId, ClusterId = "SITE-A", Host = nodeId.Split(':')[0],
ApplicationUri = $"urn:OtOpcUa:{nodeId}", Enabled = true, CreatedBy = "test",
});
}
db.SaveChanges();
}
/// <summary>Throws from exactly one <c>CreateDbContext</c> call after <see cref="FailNext"/>.</summary>
private sealed class OneShotFaultingDbFactory(IDbContextFactory<OtOpcUaConfigDbContext> inner)
: IDbContextFactory<OtOpcUaConfigDbContext>
{
private int _armed;
public bool Faulted { get; private set; }
public void FailNext() => Interlocked.Exchange(ref _armed, 1);
public OtOpcUaConfigDbContext CreateDbContext()
{
if (Interlocked.Exchange(ref _armed, 0) == 1)
{
Faulted = true;
throw new InvalidOperationException("injected transient fault seeding the expected set");
}
return inner.CreateDbContext();
}
}
}
@@ -0,0 +1,233 @@
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Retention;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// R03:1.2 — every deploy stored the whole fleet snapshot forever. The retention sweep empties the
/// blob on old terminal rows (never deletes a row, never touches an in-flight one, never the newest N,
/// never one a node applied recently).
/// </summary>
public sealed class DeploymentRetentionTests : ControlPlaneActorTestBase
{
private static readonly DateTime SweepNow = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public async Task Sweep_empties_blobs_beyond_the_keep_window_and_leaves_the_newest_intact()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 12, mixInTimedOut: false); // ids[0] newest … ids[11] oldest
var options = new DeploymentRetentionOptions { KeepSealed = 5, ReferencedDays = 30, MaxRowsPerSweep = 200 };
int pruned;
await using (var db = f.CreateDbContext())
pruned = await DeploymentRetentionActor.SweepAsync(db, options, SweepNow);
pruned.ShouldBe(7);
await using var verify = f.CreateDbContext();
foreach (var id in ids.Take(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.Length.ShouldBeGreaterThan(0, "newest 5 keep their blob");
foreach (var id in ids.Skip(5))
(await verify.Deployments.SingleAsync(d => d.DeploymentId == id)).ArtifactBlob.ShouldBeEmpty("older rows are emptied");
// Rows survive: the audit record is intact.
(await verify.Deployments.CountAsync()).ShouldBe(12);
// Idempotent: a second sweep finds nothing.
await using var again = f.CreateDbContext();
(await DeploymentRetentionActor.SweepAsync(again, options, SweepNow)).ShouldBe(0);
}
[Fact]
public async Task Sweep_keeps_a_row_a_node_applied_recently_and_never_touches_an_in_flight_row()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 4);
var inFlight = Guid.NewGuid();
await using (var db = f.CreateDbContext())
{
db.ClusterNodes.Add(new ClusterNode
{
NodeId = "site-a-1:4053", ClusterId = "SITE-A", Host = "site-a-1",
ApplicationUri = "urn:x", Enabled = true, CreatedBy = "test",
});
// ids[3] is the oldest; a node applied it yesterday, so it stays.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[3], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-1), AppliedAtUtc = SweepNow.AddDays(-1),
});
// ids[2]: applied long ago — not protected.
db.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = "site-a-1:4053", DeploymentId = ids[2], Status = NodeDeploymentStatus.Applied,
StartedAtUtc = SweepNow.AddDays(-90), AppliedAtUtc = SweepNow.AddDays(-90),
});
// An ancient in-flight row with a blob — never touched.
db.Deployments.Add(new Deployment
{
DeploymentId = inFlight, RevisionHash = new string('f', 64), Status = DeploymentStatus.AwaitingApplyAcks,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-400), ArtifactBlob = new byte[] { 1, 2, 3 },
});
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 1, ReferencedDays = 30 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2); // ids[1], ids[2]
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty("newest");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[1])).ArtifactBlob.ShouldBeEmpty();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[2])).ArtifactBlob.ShouldBeEmpty("applied 90 days ago");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[3])).ArtifactBlob.ShouldNotBeEmpty("applied yesterday");
(await verify.Deployments.SingleAsync(d => d.DeploymentId == inFlight)).ArtifactBlob.ShouldNotBeEmpty("in flight");
}
/// <summary>
/// Fix round 1 / I3 — the keep window counts SEALED rows, not "terminal" ones. A burst of 60 failed
/// deploys used to fill a 50-row terminal window on its own and push the fleet's last good (Sealed)
/// artifact out of it — the one every boot / drift / fallback path reads. The newest Sealed blob is
/// never pruned; the unreferenced TimedOut rows are (positive control: the sweep did run).
/// </summary>
[Fact]
public async Task Sixty_timed_out_deploys_never_prune_the_newest_sealed_blob()
{
var f = NewInMemoryDbFactory();
var sealedId = Guid.NewGuid();
var timedOut = new List<Guid>();
await using (var db = f.CreateDbContext())
{
db.Deployments.Add(new Deployment
{
DeploymentId = sealedId, RevisionHash = new string('s', 64), Status = DeploymentStatus.Sealed,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddDays(-200), SealedAtUtc = SweepNow.AddDays(-200),
ArtifactBlob = new byte[] { 1, 2, 3 },
});
for (var i = 0; i < 60; i++)
{
var id = Guid.NewGuid();
timedOut.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id, RevisionHash = new string('t', 64), Status = DeploymentStatus.TimedOut,
CreatedBy = "test", CreatedAtUtc = SweepNow.AddHours(-i), ArtifactBlob = new byte[] { 9 },
});
}
await db.SaveChangesAsync();
}
var options = new DeploymentRetentionOptions { KeepSealed = 50, ReferencedDays = 30, MaxRowsPerSweep = 200 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(60);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == sealedId)).ArtifactBlob
.ShouldNotBeEmpty("the newest Sealed artifact is never pruned");
(await verify.Deployments.CountAsync(d => timedOut.Contains(d.DeploymentId) && d.ArtifactBlob.Length == 0))
.ShouldBe(60);
}
/// <summary>Even with KeepSealed misconfigured to 0, the newest Sealed row keeps its blob.</summary>
[Fact]
public async Task The_newest_sealed_blob_survives_a_zero_keep_window()
{
var f = NewInMemoryDbFactory();
var ids = Seed(f, sealedCount: 3, mixInTimedOut: false);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, new DeploymentRetentionOptions { KeepSealed = 0 }, SweepNow))
.ShouldBe(2);
await using var verify = f.CreateDbContext();
(await verify.Deployments.SingleAsync(d => d.DeploymentId == ids[0])).ArtifactBlob.ShouldNotBeEmpty();
}
[Fact]
public async Task Sweep_is_bounded_per_call_and_drains_a_backlog_over_calls()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 10);
var options = new DeploymentRetentionOptions { KeepSealed = 2, MaxRowsPerSweep = 3 };
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(3);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(2);
await using (var db = f.CreateDbContext())
(await DeploymentRetentionActor.SweepAsync(db, options, SweepNow)).ShouldBe(0);
}
/// <summary>The actor runs the same rule off its thread and reports back.</summary>
[Fact]
public void Actor_sweeps_on_request_and_reports_the_count()
{
var f = NewInMemoryDbFactory();
Seed(f, sealedCount: 6);
var actor = Sys.ActorOf(DeploymentRetentionActor.Props(f, new DeploymentRetentionOptions
{
KeepSealed = 4, InitialDelaySeconds = 3600, IntervalMinutes = 60,
}));
actor.Tell(DeploymentRetentionActor.Sweep.Instance);
ExpectMsg<DeploymentRetentionActor.SweepCompleted>(PresenceBudget).Pruned.ShouldBe(2);
using var verify = f.CreateDbContext();
verify.Deployments.Count(d => d.ArtifactBlob.Length == 0).ShouldBe(2);
}
[Fact]
public void Options_bind_from_configuration_with_defaults()
{
var defaults = DeploymentRetentionOptions.Bind(null);
defaults.Enabled.ShouldBeTrue();
defaults.KeepSealed.ShouldBe(50);
defaults.ReferencedDays.ShouldBe(30);
defaults.IntervalMinutes.ShouldBe(60);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Deployments:Retention:KeepSealed"] = "7",
["Deployments:Retention:Enabled"] = "false",
})
.Build();
var bound = DeploymentRetentionOptions.Bind(config);
bound.KeepSealed.ShouldBe(7);
bound.Enabled.ShouldBeFalse();
bound.ReferencedDays.ShouldBe(30);
}
/// <summary>Seeds sealed deployments, newest first; each one hour older than the previous.</summary>
private static List<Guid> Seed(IDbContextFactory<OtOpcUaConfigDbContext> f, int sealedCount, bool mixInTimedOut = true)
{
var ids = new List<Guid>();
using var db = f.CreateDbContext();
for (var i = 0; i < sealedCount; i++)
{
var id = Guid.NewGuid();
ids.Add(id);
db.Deployments.Add(new Deployment
{
DeploymentId = id,
RevisionHash = new string('a', 64),
Status = mixInTimedOut && i % 3 == 2 ? DeploymentStatus.TimedOut : DeploymentStatus.Sealed,
CreatedBy = "test",
CreatedAtUtc = SweepNow.AddHours(-i),
SealedAtUtc = SweepNow.AddHours(-i),
ArtifactBlob = new byte[] { 0x1f, 0x8b, (byte)i },
});
}
db.SaveChanges();
return ids;
}
}
@@ -0,0 +1,77 @@
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
/// <summary>
/// A migrated, throw-away config database on the central SQL Server, for the few ControlPlane behaviours
/// the InMemory provider cannot model (a SQL Server <c>DbUpdateException</c> names the WHOLE statement
/// batch; collation-ordered reads). Same server / credentials / env overrides as
/// <c>Configuration.Tests.SchemaComplianceFixture</c> (<c>OTOPCUA_CONFIG_TEST_SERVER</c>,
/// <c>OTOPCUA_CONFIG_TEST_SA_PASSWORD</c>; default the shared docker host's dev SA), and the same database
/// name shape, so that suite's 24 h orphan reaper also reclaims a database a killed run leaks here.
/// </summary>
public sealed class SqlServerTestDatabase : IDisposable
{
private const string DefaultServer = "10.100.0.35,14330";
private const string DefaultSaPassword = "OtOpcUaDev_2026!";
/// <summary>Gets the connection string of the migrated database.</summary>
public string ConnectionString { get; }
private readonly string _databaseName;
/// <summary>Creates and migrates the database.</summary>
public SqlServerTestDatabase()
{
var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer;
var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword;
_databaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}";
ConnectionString =
$"Server={server};Database={_databaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;";
using var ctx = NewContext();
ctx.Database.Migrate();
}
/// <summary>A new context on the database.</summary>
/// <returns>The context.</returns>
public OtOpcUaConfigDbContext NewContext() =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseSqlServer(ConnectionString).Options);
/// <summary>A factory over <see cref="NewContext"/>.</summary>
/// <returns>The factory.</returns>
public IDbContextFactory<OtOpcUaConfigDbContext> Factory() => new ContextFactory(this);
/// <summary>Drops the database (best effort; a leak is reaped by the Configuration suite).</summary>
public void Dispose()
{
try
{
SqlConnection.ClearAllPools();
var master = new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString;
using var conn = new SqlConnection(master);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
DECLARE @sql nvarchar(max);
IF DB_ID(@name) IS NOT NULL
BEGIN
SET @sql = N'ALTER DATABASE ' + QUOTENAME(@name) + N' SET SINGLE_USER WITH ROLLBACK IMMEDIATE; ' +
N'DROP DATABASE ' + QUOTENAME(@name) + N';';
EXEC sp_executesql @sql;
END";
cmd.Parameters.Add(new SqlParameter("@name", _databaseName));
cmd.ExecuteNonQuery();
}
catch (Exception)
{
// Best effort — see the class remarks.
}
}
private sealed class ContextFactory(SqlServerTestDatabase owner) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => owner.NewContext();
}
}
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// R03:2.3 — an unset or placeholder pepper must fail host start, not surface as "Invalid API key"
/// on the first activation.
/// </summary>
public sealed class ApiKeyOptionsValidatorTests
{
private static ValidateOptionsResult Validate(ApiKeyOptions options) =>
new ApiKeyOptionsValidator().Validate(Options.DefaultName, options);
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Blank_pepper_fails(string pepper)
{
var result = Validate(new ApiKeyOptions { Pepper = pepper });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain("Pepper is required");
}
[Fact]
public void Short_pepper_fails()
{
var result = Validate(new ApiKeyOptions { Pepper = "x" });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain($"at least {ApiKeyOptionsValidator.MinPepperLength}");
}
[Fact]
public void Blank_username_fails()
{
var result = Validate(new ApiKeyOptions { Pepper = new string('p', 32), Username = " " });
result.Failed.ShouldBeTrue();
result.FailureMessage.ShouldNotBeNull();
result.FailureMessage.ShouldContain("Username");
}
/// <summary>Positive control: the docker-dev rig's pepper and the defaults pass unchanged.</summary>
[Fact]
public void Configured_pepper_passes()
{
Validate(new ApiKeyOptions { Pepper = "docker-dev-apikey-pepper-not-for-production" })
.Succeeded.ShouldBeTrue();
}
}
@@ -49,6 +49,41 @@ public sealed class ActiveTierRegistrationTests
ClusterPrimaryRegistration(hasAdmin).Tags.ShouldContain(ZbHealthTags.Active); ClusterPrimaryRegistration(hasAdmin).Tags.ShouldContain(ZbHealthTags.Active);
} }
/// <summary>
/// R03:2.1 + fix round 1 / I6 (coordinator ruling) — the OPC UA endpoint probe's tiers by role:
/// <list type="bullet">
/// <item>driver-only: Ready + Active (it cannot serve and must not be routed to);</item>
/// <item>fused admin+driver: Ready ONLY, until the ServiceLevel calculator consumes the fact
/// (F-C1) — Traefik routes the AdminUI on <c>/health/active</c>, so a failed SDK on the active
/// central node must not take the AdminUI out of rotation;</item>
/// <item>admin-only: absent (it starts no endpoint to be degraded by).</item>
/// </list>
/// </summary>
[Theory]
[InlineData(false, true, true, true)]
[InlineData(true, true, true, false)]
[InlineData(true, false, false, false)]
public void Opcua_endpoint_probe_tiers_follow_the_node_roles(bool hasAdmin, bool hasDriver, bool ready, bool active)
{
var registrations = new ServiceCollection()
.AddOtOpcUaHealth(hasAdmin: hasAdmin, hasDriver: hasDriver)
.BuildServiceProvider()
.GetRequiredService<IOptions<HealthCheckServiceOptions>>()
.Value.Registrations
.Where(r => r.Name == OpcUaEndpointHealthCheck.Name)
.ToList();
if (!ready && !active)
{
registrations.ShouldBeEmpty();
return;
}
var registration = registrations.ShouldHaveSingleItem();
registration.Tags.Contains(ZbHealthTags.Ready).ShouldBe(ready);
registration.Tags.Contains(ZbHealthTags.Active).ShouldBe(active);
}
[Fact] [Fact]
public void Active_tier_check_is_not_on_the_ready_tier() public void Active_tier_check_is_not_on_the_ready_tier()
{ {
@@ -0,0 +1,203 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// R03:3.3 — the negative cache was keyed by key id, so a flood of DISTINCT well-formed ids cost one
/// SQL query per attempt under the SessionManager lock, no credential needed. Two guards:
/// the known-id set (unknown ids refused in memory) and the miss budget (a token bucket on lookups
/// that reach the store and miss, for when the set is unavailable).
/// </summary>
public sealed class BoundedApiKeyStoreKnownIdTests
{
private static readonly DateTimeOffset T0 = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task An_unknown_id_is_refused_without_reaching_the_store_and_a_known_one_still_does()
{
var inner = new ListingStore("k-real");
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
(await sut.FindAsync("k-guess-1", T0.UtcDateTime, default)).ShouldBeNull();
(await sut.FindAsync("k-guess-2", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(0, "unknown ids never reach the store");
inner.ListCalls.ShouldBe(1, "one set load serves every unknown id inside the TTL");
// Positive control: the real id reaches the store and is found.
(await sut.FindAsync("k-real", T0.UtcDateTime, default)).ShouldNotBeNull();
inner.FindCalls.ShouldBe(1);
}
[Fact]
public async Task The_set_is_refreshed_after_the_TTL_so_a_newly_minted_key_becomes_visible()
{
var inner = new ListingStore("k-old");
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
(await sut.FindAsync("k-new", T0.UtcDateTime, default)).ShouldBeNull();
inner.Mint("k-new");
// Still inside the TTL: the stale set refuses it (the documented window).
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(0);
clock.Advance(TimeSpan.FromSeconds(3));
(await sut.FindAsync("k-new", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
inner.ListCalls.ShouldBe(2);
}
[Fact]
public async Task A_failed_set_load_falls_back_to_the_per_id_lookup_and_keeps_an_older_set()
{
var inner = new ListingStore("k-real") { ListThrows = true };
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 5);
// No set yet and the load fails: the per-id path runs (old behaviour), nothing is wedged.
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(1);
sut.KnownKeyIds.ShouldBeNull();
inner.ListThrows = false;
clock.Advance(TimeSpan.FromSeconds(6));
(await sut.FindAsync("k-guess-2", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
sut.KnownKeyIds.ShouldNotBeNull();
inner.FindCalls.ShouldBe(1, "once the set loaded, the guess was refused in memory");
// A later failed refresh keeps the previous set in force.
inner.ListThrows = true;
clock.Advance(TimeSpan.FromSeconds(6));
(await sut.FindAsync("k-guess-3", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(1);
}
/// <summary>With no id set available, misses are rate-limited; hits are not charged; time refills.</summary>
[Fact]
public async Task The_miss_budget_bounds_store_misses_when_no_set_is_available()
{
var inner = new ListingStore("k-real") { SupportsListing = false };
var clock = new FakeClock(T0);
var sut = Bounded(inner, clock, cacheSeconds: 0, missBurst: 3, refillPerSecond: 1);
for (var i = 0; i < 3; i++)
(await sut.FindAsync($"k-guess-{i}", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(3);
// Budget exhausted: refused without a store read, as a throw the authenticator denies on.
await Should.ThrowAsync<InvalidOperationException>(() => sut.FindAsync("k-guess-9", T0.UtcDateTime, default));
inner.FindCalls.ShouldBe(3);
// Refill: one second restores one token.
clock.Advance(TimeSpan.FromSeconds(1));
(await sut.FindAsync("k-guess-10", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(4);
// A hit is not a miss: it reaches the store even with the bucket empty? No — it needs a token to
// be attempted, but the token is refunded, so the next attempt still has it. Prove the refund.
clock.Advance(TimeSpan.FromSeconds(1));
(await sut.FindAsync("k-real", clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
(await sut.FindAsync("k-guess-11", clock.GetUtcNow().UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(6);
}
[Fact]
public async Task The_guards_are_off_when_the_cache_is_off_restoring_a_read_per_lookup()
{
var inner = new ListingStore("k-real");
var sut = Bounded(inner, new FakeClock(T0), cacheSeconds: 0, missBurst: 0);
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
(await sut.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
inner.FindCalls.ShouldBe(2);
inner.ListCalls.ShouldBe(0);
}
/// <summary>
/// Fix round 1 minor — an inner store that cannot list ids switches the known-id set off for THIS
/// decorator only. It used to do so by writing <c>KnownIdSet = false</c> into the guard options it was
/// handed, a shared options object, so one store's capability leaked into every other consumer.
/// Positive control: a second decorator over a listing store on the same options instance still uses
/// the set.
/// </summary>
[Fact]
public async Task An_unlisting_store_does_not_mutate_the_shared_guard_options()
{
var guard = new ApiKeyStoreGuardOptions { MissBurst = 100, MissRefillPerSecond = 20 };
var options = Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = 5,
});
var clock = new FakeClock(T0);
var unlisting = new ListingStore("k-real") { SupportsListing = false };
var first = new BoundedApiKeyStore(unlisting, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await first.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
unlisting.FindCalls.ShouldBe(1, "no set: the per-id path ran");
guard.KnownIdSet.ShouldBeTrue("the shared options object is never written");
var listing = new ListingStore("k-real");
var second = new BoundedApiKeyStore(listing, options, NullLogger<BoundedApiKeyStore>.Instance, clock, guard);
(await second.FindAsync("k-guess", T0.UtcDateTime, default)).ShouldBeNull();
listing.FindCalls.ShouldBe(0, "the second decorator still refuses unknown ids in memory");
listing.ListCalls.ShouldBe(1);
}
private static BoundedApiKeyStore Bounded(
IApiKeyStore inner, TimeProvider clock, int cacheSeconds, int missBurst = 100, double refillPerSecond = 20) => new(
inner,
Options.Create(new ApiKeyOptions
{
Username = "apikey", Pepper = "unit-test-pepper-xxxx", StoreTimeoutMs = 0, StoreCacheSeconds = cacheSeconds,
}),
NullLogger<BoundedApiKeyStore>.Instance,
clock,
new ApiKeyStoreGuardOptions { MissBurst = missBurst, MissRefillPerSecond = refillPerSecond });
private sealed class ListingStore(params string[] ids) : IApiKeyStore
{
private readonly HashSet<string> _ids = ids.ToHashSet(StringComparer.Ordinal);
private int _findCalls;
private int _listCalls;
public bool SupportsListing { get; set; } = true;
public bool ListThrows { get; set; }
public int FindCalls => _findCalls;
public int ListCalls => _listCalls;
public void Mint(string id) => _ids.Add(id);
public Task<ApiKeyLookup?> FindAsync(string apiKeyId, DateTime utcNow, CancellationToken ct)
{
Interlocked.Increment(ref _findCalls);
return Task.FromResult(_ids.Contains(apiKeyId)
? new ApiKeyLookup(new ApiKey { ApiKeyId = apiKeyId, Name = "k", SecretHash = "h", Principal = "p" }, [])
: null);
}
public Task<IReadOnlyList<NodeAcl>> FindGrantsAsync(string principal, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<NodeAcl>>([]);
public Task<IReadOnlySet<string>?> ListKeyIdsAsync(CancellationToken ct)
{
if (!SupportsListing) return Task.FromResult<IReadOnlySet<string>?>(null);
Interlocked.Increment(ref _listCalls);
if (ListThrows) throw new InvalidOperationException("ConfigDb unreachable (simulated)");
return Task.FromResult<IReadOnlySet<string>?>(_ids.ToHashSet(StringComparer.Ordinal));
}
}
private sealed class FakeClock(DateTimeOffset utcNow) : TimeProvider
{
private DateTimeOffset _now = utcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
@@ -363,7 +363,10 @@ public sealed class BoundedApiKeyStoreTests
{ {
var (_, inner) = SetUp(); var (_, inner) = SetUp();
var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero)); var clock = new FakeClock(new DateTimeOffset(2026, 8, 20, 12, 0, 0, TimeSpan.Zero));
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock); // The miss budget (R03:3.3) is OFF here on purpose: this fact floods 1025 distinct unknown ids — the
// exact shape the budget refuses — to pin the cache CEILING, which is a separate rule.
var sut = Bounded(inner, timeoutMs: 0, cacheSeconds: 600, clock,
guard: new ApiKeyStoreGuardOptions { MissBurst = 0 });
// Seed the real key, then flood past the ceiling with ids that do not exist. // Seed the real key, then flood past the ceiling with ids that do not exist.
(await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull(); (await sut.FindAsync(inner.KeyId, clock.GetUtcNow().UtcDateTime, default)).ShouldNotBeNull();
@@ -477,7 +480,8 @@ public sealed class BoundedApiKeyStoreTests
int timeoutMs, int timeoutMs,
int cacheSeconds, int cacheSeconds,
TimeProvider? clock = null, TimeProvider? clock = null,
ILogger<BoundedApiKeyStore>? log = null) => new( ILogger<BoundedApiKeyStore>? log = null,
ApiKeyStoreGuardOptions? guard = null) => new(
inner, inner,
Options.Create(new ApiKeyOptions Options.Create(new ApiKeyOptions
{ {
@@ -487,7 +491,8 @@ public sealed class BoundedApiKeyStoreTests
StoreCacheSeconds = cacheSeconds, StoreCacheSeconds = cacheSeconds,
}), }),
log ?? NullLogger<BoundedApiKeyStore>.Instance, log ?? NullLogger<BoundedApiKeyStore>.Instance,
clock ?? TimeProvider.System); clock ?? TimeProvider.System,
guard);
private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new( private static ApiKeyOpcUaUserAuthenticator Auth(IApiKeyStore store) => new(
store, store,
@@ -0,0 +1,213 @@
using System.Net;
using System.Net.Sockets;
using Akka.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Host.Health;
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
/// <summary>
/// R03:2.1 / 1.4 — a failed OPC UA SDK start used to be one Error line: the node stayed Ready, joined
/// its mesh, and acked deploys through the Null sink while clients got connection-refused. The start
/// outcome is now a fact (<see cref="IOpcUaEndpointState"/>) the ready/active tiers read, a driver-only
/// node rethrows so the process exits, and <c>StopAsync</c> actually stops the SDK.
/// </summary>
public sealed class OtOpcUaServerHostedServiceStartFailureTests
{
private static readonly TimeSpan Deadline = TimeSpan.FromSeconds(30);
/// <summary>
/// Port already bound ⇒ the SDK cannot listen ⇒ the state is Failed, the probe is Unhealthy and the
/// real <c>/health/ready</c> endpoint answers 503 — while StartAsync itself returns (fused node).
/// Positive control: the same endpoint answers 200 once the state is Started.
/// </summary>
[Fact]
public async Task Port_in_use_marks_the_endpoint_failed_and_health_ready_answers_503()
{
using var deadline = new CancellationTokenSource(Deadline);
using var squatter = SquatAllInterfaces(out var port);
var pki = NewPki();
var state = new OpcUaEndpointState();
var fused = EffectiveRoles.Resolve(new[] { "admin", "driver" }, envRoles: null);
var service = NewService(port, pki, state, fused);
try
{
await service.StartAsync(deadline.Token); // must NOT throw on a fused node
state.Status.ShouldBe(OpcUaEndpointStatus.Failed);
state.FailureReason.ShouldNotBeNullOrWhiteSpace();
var probe = await new OpcUaEndpointHealthCheck(state).CheckHealthAsync(new HealthCheckContext());
probe.Status.ShouldBe(HealthStatus.Unhealthy);
probe.Description.ShouldNotBeNull();
probe.Description.ShouldContain("failed to start");
await using var web = BuildHealthHost(state);
await web.StartAsync(deadline.Token);
using var http = new HttpClient { BaseAddress = new Uri(web.Urls.First()) };
(await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
(await http.GetAsync("/health/active", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
// Positive control on the endpoint: the same host answers 200 for a started endpoint, so the
// 503 above measured the probe and not a broken health pipeline.
state.MarkStarted();
(await http.GetAsync("/health/ready", deadline.Token)).StatusCode.ShouldBe(HttpStatusCode.OK);
await web.StopAsync(deadline.Token);
}
finally
{
await service.DisposeAsync();
squatter.Stop();
Cleanup(pki);
}
}
/// <summary>A driver-only node has nothing else to stay up for: the failed start propagates.</summary>
[Fact]
public async Task Driver_only_node_rethrows_when_the_sdk_fails_to_start()
{
using var deadline = new CancellationTokenSource(Deadline);
using var squatter = SquatAllInterfaces(out var port);
var pki = NewPki();
var state = new OpcUaEndpointState();
var driverOnly = EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null);
var service = NewService(port, pki, state, driverOnly);
try
{
await Should.ThrowAsync<Exception>(() => service.StartAsync(deadline.Token));
state.Status.ShouldBe(OpcUaEndpointStatus.Failed);
}
finally
{
await service.DisposeAsync();
squatter.Stop();
Cleanup(pki);
}
}
/// <summary>
/// A clean start is Started and listening; <c>StopAsync</c> closes the port (connection refused, not
/// a stale-but-listening endpoint) and marks Stopped; <c>DisposeAsync</c> twice afterwards is a no-op.
/// </summary>
[Fact]
public async Task StopAsync_closes_the_port_and_DisposeAsync_is_idempotent()
{
using var deadline = new CancellationTokenSource(Deadline);
var port = FreePort();
var pki = NewPki();
var state = new OpcUaEndpointState();
var service = NewService(port, pki, state, EffectiveRoles.Resolve(new[] { "driver" }, envRoles: null));
try
{
await service.StartAsync(deadline.Token);
state.Status.ShouldBe(OpcUaEndpointStatus.Started);
using (var client = new TcpClient())
{
await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token);
client.Connected.ShouldBeTrue("positive control: the endpoint is listening after StartAsync");
}
await service.StopAsync(deadline.Token);
state.Status.ShouldBe(OpcUaEndpointStatus.Stopped);
using (var client = new TcpClient())
{
await Should.ThrowAsync<SocketException>(async () => await client.ConnectAsync(IPAddress.Loopback, port, deadline.Token));
}
await service.DisposeAsync();
await service.DisposeAsync(); // idempotent
}
finally
{
await service.DisposeAsync();
Cleanup(pki);
}
}
private static OtOpcUaServerHostedService NewService(int port, string pki, OpcUaEndpointState state, EffectiveRoles roles) =>
new(
Options.Create(new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.StartFailure",
ApplicationUri = $"urn:OtOpcUa.StartFailure:{Guid.NewGuid():N}",
OpcUaPort = port, PublicHostname = "localhost", PkiStoreRoot = pki,
}),
new DeferredAddressSpaceSink(), new DeferredServiceLevelPublisher(),
NullOpcUaUserAuthenticator.Instance, NullHistorianDataSource.Instance,
EmptyDataPlaneAuthoritySource.Instance, new ConfigurationBuilder().Build(),
() => throw new NotSupportedException("no actor system required"), new ActorRegistry(),
null, Options.Create(new AclOptions()), TimeProvider.System, NullLoggerFactory.Instance,
endpointState: state, roles: roles);
/// <summary>The production health registration (driver role) over the supplied state, on a loopback Kestrel.</summary>
private static WebApplication BuildHealthHost(OpcUaEndpointState state)
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Logging.ClearProviders();
builder.Services.AddSingleton<IOpcUaEndpointState>(state);
builder.Services.AddOtOpcUaHealth(hasAdmin: false, hasDriver: true);
// The akka + cluster-primary probes need a running ActorSystem; keep only the probe under test so
// the status measured is the endpoint's. (The registration shape itself is pinned separately.)
builder.Services.Configure<HealthCheckServiceOptions>(o =>
{
foreach (var r in o.Registrations.Where(r => r.Name != OpcUaEndpointHealthCheck.Name).ToList())
o.Registrations.Remove(r);
});
var app = builder.Build();
app.MapOtOpcUaHealth();
return app;
}
/// <summary>
/// Holds a port on the IPv4 AND IPv6 wildcard addresses. The SDK binds its listener on the
/// wildcards, and on macOS a loopback-only squatter does not conflict with a wildcard bind (both
/// sockets carry SO_REUSEADDR), so a loopback squatter would let the SDK start — the first run of
/// this test proved exactly that. The SDK also only fails when NEITHER family binds.
/// </summary>
private static TcpListener SquatAllInterfaces(out int port)
{
var squatter = new TcpListener(IPAddress.IPv6Any, 0);
squatter.Server.DualMode = true;
squatter.ExclusiveAddressUse = true;
squatter.Start();
port = ((IPEndPoint)squatter.LocalEndpoint).Port;
return squatter;
}
private static int FreePort()
{
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
private static string NewPki() => Path.Combine(Path.GetTempPath(), $"otopcua-startfail-{Guid.NewGuid():N}");
private static void Cleanup(string pki)
{
if (Directory.Exists(pki)) Directory.Delete(pki, recursive: true);
}
}
@@ -0,0 +1,116 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.OtOpcUa.Security.Audit;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit;
/// <summary>
/// R03:2.5 — inside a Blazor circuit there is no <c>HttpContext</c> after the first render, so an
/// HTTP-only accessor stamps every audit row from an interactive page <c>"system"</c>. The circuit's
/// <see cref="AuthenticationStateProvider"/> is consulted first; the HTTP context is the fallback.
/// </summary>
public sealed class AuthenticationStateAuditActorAccessorTests
{
[Fact]
public void Circuit_principal_wins_when_there_is_no_http_context()
{
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice"))));
accessor.CurrentActor.ShouldBe("alice");
}
[Fact]
public void Http_principal_is_the_fallback_outside_a_circuit()
{
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) };
var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null);
accessor.CurrentActor.ShouldBe("bob");
}
[Fact]
public void Unauthenticated_circuit_falls_through_to_http_then_null()
{
var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity()));
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit)
.CurrentActor.ShouldBeNull();
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) };
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit)
.CurrentActor.ShouldBe("carol");
}
/// <summary>An incomplete state task is "no circuit identity", never a block.</summary>
[Fact]
public void Pending_authentication_state_is_not_awaited()
{
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new PendingStateProvider());
accessor.CurrentActor.ShouldBeNull();
}
/// <summary>
/// Fix round 1 / I5 — the REAL server-side provider, resolved in a non-circuit scope (a minimal-API
/// request, a background service), throws <see cref="InvalidOperationException"/> from
/// <c>GetAuthenticationStateAsync</c> because nothing called <c>SetAuthenticationState</c>. That used
/// to escape <c>CurrentActor</c>; it must fall back to the HTTP principal instead.
/// </summary>
[Fact]
public void Real_server_provider_outside_a_circuit_falls_back_to_the_http_principal()
{
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "dave")) };
var accessor = new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(context),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider());
accessor.CurrentActor.ShouldBe("dave");
// No HTTP principal either ⇒ null, still no throw.
new AuthenticationStateAuditActorAccessor(
new HttpContextAccessorStub(null),
new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider())
.CurrentActor.ShouldBeNull();
}
/// <summary>Positive control on the real provider: once a circuit has set its state, that principal wins.</summary>
[Fact]
public void Real_server_provider_inside_a_circuit_serves_the_circuit_principal()
{
var provider = new Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider();
provider.SetAuthenticationState(Task.FromResult(
new AuthenticationState(Authenticated(new Claim(ZbClaimTypes.Username, "erin")))));
var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "http-user")) };
new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), provider)
.CurrentActor.ShouldBe("erin");
}
private static ClaimsPrincipal Authenticated(params Claim[] claims) =>
new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role));
private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; } = context;
}
private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(user));
}
private sealed class PendingStateProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
new TaskCompletionSource<AuthenticationState>().Task;
}
}
@@ -11,6 +11,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.Tokens;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.AspNetCore;
@@ -37,6 +39,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
private IHost _host = null!; private IHost _host = null!;
private TestServer _server = null!; private TestServer _server = null!;
private readonly StubLdapGroupRoleMappingService _roleMappings = new(); private readonly StubLdapGroupRoleMappingService _roleMappings = new();
private readonly CapturingLoggerProvider _logs = new();
private static CancellationToken Ct => TestContext.Current.CancellationToken; private static CancellationToken Ct => TestContext.Current.CancellationToken;
@@ -70,6 +73,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer", ["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer",
}).Build(); }).Build();
services.AddOtOpcUaAuth(configuration); services.AddOtOpcUaAuth(configuration);
services.AddLogging(l => l.AddProvider(_logs));
services.AddSingleton<ILdapAuthService, StubLdapAuthService>(); services.AddSingleton<ILdapAuthService, StubLdapAuthService>();
// The login handler now resolves the DB role-map service via DI to merge // The login handler now resolves the DB role-map service via DI to merge
// DB-backed grants on top of the appsettings baseline. Register the stub so // DB-backed grants on top of the appsettings baseline. Register the stub so
@@ -96,6 +100,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
.ToArray(); .ToArray();
return Results.Ok(claims); return Results.Ok(claims);
}).RequireAuthorization(); }).RequireAuthorization();
// A role-gated machine endpoint: proves a bearer principal's roles resolve through
// IsInRole (the validation parameters pair RoleClaimType with the minted key).
e.MapGet("/api/viewer-only", () => Results.Ok("viewer"))
.RequireAuthorization(p => p.RequireRole("Viewer"));
}); });
}); });
}) })
@@ -153,6 +161,158 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime
new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct); new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct);
response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable);
// R03:2.7 — the outage is named in the server log, not inferred from the directory side.
_logs.Entries.Any(e =>
e.Level == LogLevel.Warning && e.Message.Contains("LDAP authentication failed") && e.Exception != null)
.ShouldBeTrue("a Warning naming the LDAP failure must be logged");
}
/// <summary>
/// R03:2.4 — a token from <c>/auth/token</c> authenticates a request carrying it as a bearer:
/// the fallback-protected root AND a role-gated endpoint (the bearer principal's "Role" claims
/// resolve through <c>IsInRole</c>). No cookie is sent.
/// </summary>
[Fact]
public async Task Bearer_token_authenticates_api_requests_without_a_cookie()
{
var token = await LoginAndIssueTokenAsync();
var client = NewClient();
var root = new HttpRequestMessage(HttpMethod.Get, "/");
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
var gated = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
gated.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var gatedResponse = await client.SendAsync(gated, Ct);
gatedResponse.StatusCode.ShouldBe(HttpStatusCode.OK);
(await gatedResponse.Content.ReadAsStringAsync(Ct)).ShouldContain("viewer");
}
/// <summary>A tampered or garbage bearer fails closed (401), it never falls through to anonymous.</summary>
[Theory]
[InlineData("not-a-jwt")]
[InlineData("tampered")]
public async Task Bad_bearer_token_is_rejected(string kind)
{
var token = kind == "tampered"
? (await LoginAndIssueTokenAsync()) + "x"
: kind;
var client = NewClient();
var req = new HttpRequestMessage(HttpMethod.Get, "/");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("X-Requested-With", "XMLHttpRequest");
var response = await client.SendAsync(req, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
}
/// <summary>
/// Fix round 1 / I1 — the first-party JwtBearer handler pins HS256: an unsigned (<c>alg: none</c>)
/// token carrying the right issuer/audience/roles, and a token signed with a different HMAC
/// variant, both fail CLOSED with a 401 — never a 500, never a pass.
/// </summary>
[Theory]
[InlineData("none")]
[InlineData("HS384")]
public async Task Bearer_with_a_non_HS256_algorithm_is_rejected_with_401(string alg)
{
var token = alg == "none" ? UnsignedToken() : SignedToken(SecurityAlgorithms.HmacSha384);
var client = NewClient();
var req = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await client.SendAsync(req, Ct);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer");
// Positive control: the same claims signed HS256 with the configured key ARE accepted, so the
// 401 above measured the algorithm and not a broken token shape.
var good = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only");
good.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", SignedToken(SecurityAlgorithms.HmacSha256));
(await client.SendAsync(good, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
}
/// <summary>
/// Fix round 1 / I2 — <c>/auth/token</c> mints only for a COOKIE session. A bearer token used to
/// be able to mint a fresh token for itself, so a stolen token could renew itself forever and
/// outlive both its own expiry and the user's LDAP membership. Positive control: the cookie
/// session that minted the bearer still gets a token.
/// </summary>
[Fact]
public async Task Auth_token_refuses_a_bearer_only_caller_and_serves_a_cookie_session()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var cookieReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(cookieReq, loginResponse);
var cookieResp = await client.SendAsync(cookieReq, Ct);
cookieResp.StatusCode.ShouldBe(HttpStatusCode.OK);
var bearer = (await cookieResp.Content.ReadFromJsonAsync<JsonElement>(Ct)).GetProperty("token").GetString()!;
// The bearer authenticates elsewhere (positive control on the token itself) …
var root = new HttpRequestMessage(HttpMethod.Get, "/");
root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
(await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
// … but cannot renew itself.
var renew = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
renew.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", bearer);
renew.Headers.Add("X-Requested-With", "XMLHttpRequest");
var renewResp = await client.SendAsync(renew, Ct);
renewResp.StatusCode.ShouldBeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden);
(await renewResp.Content.ReadAsStringAsync(Ct)).ShouldNotContain("token");
}
private const string TestSigningKey = "test-signing-key-with-at-least-32-bytes-of-utf8-content";
private static IEnumerable<System.Security.Claims.Claim> ViewerClaims() =>
[
new(JwtTokenService.UsernameClaimType, "mallory"),
new(JwtTokenService.RoleClaimType, "Viewer"),
];
/// <summary>A token signed with <paramref name="algorithm"/> using the configured key bytes.</summary>
private static string SignedToken(string algorithm)
{
var keyBytes = System.Text.Encoding.UTF8.GetBytes(TestSigningKey);
// The SAME key bytes for every algorithm, so a rejection measures the algorithm pin alone.
var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
issuer: "otopcua-test", audience: "otopcua-test", claims: ViewerClaims(),
expires: DateTime.UtcNow.AddMinutes(5),
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(keyBytes), algorithm));
return new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>An <c>alg: none</c> token with otherwise valid issuer/audience/lifetime/roles.</summary>
private static string UnsignedToken()
{
static string B64(string json) => Microsoft.IdentityModel.Tokens.Base64UrlEncoder.Encode(json);
var exp = DateTimeOffset.UtcNow.AddMinutes(5).ToUnixTimeSeconds();
return B64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." +
B64($"{{\"zb:username\":\"mallory\",\"Role\":\"Viewer\",\"iss\":\"otopcua-test\",\"aud\":\"otopcua-test\",\"exp\":{exp}}}") +
".";
}
private async Task<string> LoginAndIssueTokenAsync()
{
var client = NewClient();
var loginResponse = await client.PostAsJsonAsync("/auth/login",
new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
loginResponse.EnsureSuccessStatusCode();
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
AttachCookies(tokenReq, loginResponse);
var response = await client.SendAsync(tokenReq, Ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(Ct);
return payload.GetProperty("token").GetString()!;
} }
/// <summary>Tests that ping anonymous returns 401.</summary> /// <summary>Tests that ping anonymous returns 401.</summary>
@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>Collects every log entry written through the host's logger factory, for assertions on what was logged.</summary>
internal sealed class CapturingLoggerProvider : ILoggerProvider
{
public ConcurrentQueue<Entry> Entries { get; } = new();
public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries);
public void Dispose() { }
internal sealed record Entry(string Category, LogLevel Level, string Message, Exception? Exception);
private sealed class Logger(string category, ConcurrentQueue<Entry> entries) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
entries.Enqueue(new Entry(category, logLevel, formatter(state, exception), exception));
}
}
@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
/// <summary>
/// R03:2.4 — JWT is opt-in by signing key. Unconfigured: <c>/auth/token</c> is not mapped (it used to
/// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the
/// host refuses to START rather than failing on the first token request.
/// </summary>
public sealed class JwtConfigurationGateTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Fact]
public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored()
{
using var host = await StartHostAsync(signingKey: null);
var server = host.GetTestServer();
var client = server.CreateClient();
// The login still works — the cookie path is untouched.
var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct);
login.StatusCode.ShouldBe(HttpStatusCode.NoContent);
// /auth/token is simply absent (404), not a 500.
var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token");
foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]);
(await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound);
// No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins.
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
(await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull();
var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/");
foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]);
withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage");
(await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK);
await host.StopAsync(Ct);
}
/// <summary>Positive control for the gate: with a key, the bearer scheme IS registered.</summary>
[Fact]
public async Task With_a_signing_key_the_bearer_scheme_is_registered()
{
using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content");
var schemes = host.Services.GetRequiredService<IAuthenticationSchemeProvider>();
var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer);
bearer.ShouldNotBeNull();
bearer.HandlerType.ShouldBe(typeof(Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerHandler));
await host.StopAsync(Ct);
}
/// <summary>A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token.</summary>
[Fact]
public async Task A_short_signing_key_fails_host_start()
{
var ex = await Should.ThrowAsync<OptionsValidationException>(async () =>
{
using var host = await StartHostAsync(signingKey: "too-short");
});
ex.Message.ShouldContain("SigningKey");
}
private static async Task<IHost> StartHostAsync(string? signingKey)
{
var dbName = $"jwt-gate-{Guid.NewGuid():N}";
var settings = new Dictionary<string, string?>();
if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var host = new HostBuilder()
.ConfigureWebHost(web =>
{
web.UseTestServer();
web.ConfigureServices(services =>
{
services.AddDbContextFactory<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddDbContext<OtOpcUaConfigDbContext>(opt => opt.UseInMemoryDatabase(dbName));
services.AddRouting();
services.AddOtOpcUaAuth(configuration);
services.AddSingleton<ILdapAuthService, AlwaysAliceLdap>();
services.AddSingleton<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
});
web.Configure(app =>
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(e =>
{
e.MapOtOpcUaAuth();
e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization();
});
});
})
.Build();
await host.StartAsync(Ct);
return host;
}
private sealed class AlwaysAliceLdap : ILdapAuthService
{
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
Task.FromResult(new LdapAuthResult(
Success: password == "valid-password", DisplayName: "Alice", Username: username,
Groups: [], Roles: [], Error: null));
}
}