diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs new file mode 100644 index 00000000..b3932f65 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbRegistration.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.LocalDb; +using ZB.MOM.WW.LocalDb.Replication; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Single registration point for the node-local LocalDb subsystem: the embedded SQLite store +/// that caches the deployed-configuration artifact, plus the optional gRPC replication that +/// mirrors it to the node's redundant pair peer. +/// +/// +/// +/// Exists as a named extension rather than inline AddZbLocalDb calls in +/// Program.cs so the wiring is covered by a DI resolution test — Program.cs is +/// top-level statements and cannot be exercised directly, which is exactly how a +/// "registered but never resolvable" defect ships unnoticed. This family has shipped that +/// defect three times (Secrets 0.2.0, Secrets 0.2.2, ScadaBridge #22). +/// +/// +/// Driver-role nodes only. Admin-only nodes have no deployed configuration to cache, +/// and registering here would impose the LocalDb:Path-required-or-no-boot constraint +/// on them for nothing. +/// +/// +/// Storage is unconditional; replication is default-OFF. A node with no +/// PeerAddress and no SyncListenPort is simply a fast local SQLite file — +/// AddZbLocalDbReplication registers the engine but it stays idle. Replication is +/// opt-in per pair because there is no production distribution story for the sync +/// ApiKey yet (the same open question the Secrets KEK has). +/// +/// +public static class LocalDbRegistration +{ + /// Configuration section holding LocalDbOptions. + public const string LocalDbSectionPath = "LocalDb"; + + /// Configuration section holding ReplicationOptions. + public const string ReplicationSectionPath = "LocalDb:Replication"; + + /// + /// Configuration key carrying the dedicated h2c sync listener's port. Zero or absent means + /// no listener is bound and the passive sync endpoint is not mapped. + /// + public const string SyncListenPortKey = "LocalDb:SyncListenPort"; + + /// + /// The port the dedicated cleartext-HTTP/2 sync listener should bind, or 0 when the + /// node should not listen at all. Defaults to 0 — absent configuration must mean + /// "off". + /// + /// The application configuration. + /// The configured sync port, or 0. + public static int SyncListenPort(IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(configuration); + return configuration.GetValue(SyncListenPortKey, defaultValue: 0); + } + + /// + /// Registers the local database (with the deployment-cache schema applied and registered for + /// replication) and the replication engine. + /// + /// + /// + /// runs inside AddZbLocalDb's singleton factory, + /// before the first caller receives the ILocalDb — so every consumer is guaranteed + /// to see the tables created and their capture triggers installed. + /// + /// + /// AddZbLocalDbReplication is called unconditionally rather than behind an + /// Enabled flag because the library already models "off" as "no + /// PeerAddress": the initiator background service idles and the passive endpoint + /// is only reachable if Program.cs maps it, which it does only when + /// is set. Adding a second gate on top would give two + /// ways to express the same thing and a way for them to disagree. + /// + /// + /// The service collection to add to. + /// The application configuration. + /// The same instance, for chaining. + public static IServiceCollection AddOtOpcUaLocalDb( + this IServiceCollection services, + IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.AddZbLocalDb(configuration, LocalDbSetup.OnReady); + services.AddZbLocalDbReplication(configuration); + + return services; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index c332916e..30c36854 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -174,6 +174,20 @@ if (hasDriver) builder.Configuration, (_, sp) => GatewayHistorian.CreateAlarmWriter(serverHistorianOptions, sp)); + // Node-local LocalDb: caches the deployed-configuration artifact so this node can boot from + // its last-known-good config when central SQL is unreachable, and optionally replicates that + // cache to its redundant pair peer. Driver-role only — admin-only nodes have nothing to cache, + // and registering here would impose LocalDb:Path-required-or-no-boot on them for nothing. + // Storage ships unconditionally; replication stays inert until LocalDb:Replication:PeerAddress + // (initiator) / LocalDb:SyncListenPort (listener) are set. See LocalDbRegistration. + builder.Services.AddOtOpcUaLocalDb(builder.Configuration); + + // gRPC server plumbing for the passive sync endpoint mapped below. Registered only under + // hasDriver so admin-only nodes expose no sync surface at all. The interceptor is the ONLY + // inbound auth on that endpoint — the replication library's LocalDbSyncService verifies + // nothing — and it fail-closes when no ApiKey is configured. + builder.Services.AddGrpc(o => o.Interceptors.Add()); + // Config-gated server-side HistoryRead backend. When the ServerHistorian section is enabled this // overrides the NullHistorianDataSource default from AddOtOpcUaRuntime (last registration wins) with // a read-only HistorianGateway-backed data source the node manager's HistoryRead overrides diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json index a955665b..e7ca5cd6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -59,6 +59,16 @@ "Capacity": 1000000, "DeadLetterRetentionDays": 30 }, + "LocalDb": { + "_comment": "Node-local SQLite store caching the deployed-configuration artifact, so a driver node can boot from its last-known-good config when central SQL is unreachable. Registered on driver-role nodes only (see LocalDbRegistration). Storage is unconditional; replication to the redundant pair peer is default-OFF.", + "Path": "./data/otopcua-localdb.db", + "_SyncListenPortComment": "Port for the dedicated cleartext-HTTP/2 (h2c) listener serving the passive sync endpoint. 0 = no listener bound and the endpoint is not mapped. Must be a port distinct from the main HTTP listener: prior-knowledge h2c cannot share a cleartext Http1AndHttp2 port.", + "SyncListenPort": 0, + "Replication": { + "_comment": "Empty = passive/off. Set PeerAddress on exactly ONE node of the pair (the stream is bidirectional, so the other side still pushes its own deltas back). ApiKey must be BYTE-IDENTICAL on both nodes — the host interceptor fail-closes, so a typo silently stops convergence rather than degrading to unauthenticated. Supply it via ${secret:...} in production, never committed.", + "_MaxBatchSizeComment": "Row count, NOT bytes, against gRPC's 4 MB default message cap. Artifact chunk rows are ~171 KB base64, so keep this at 16 on the pair (16 x 171 KB ~= 2.7 MB worst case)." + } + }, "Deployment": { "_comment": "R2-11 (05/CONV-2): deploy-gate TagConfig strictness. Warn (default) = non-blocking warnings logged + appended to the deployment result message; Error = a config with a typo'd enum or unparseable TagConfig is rejected at the draft gate. Running servers are untouched; the gate only sees re-deploys.", "TagConfigValidationMode": "Warn"