diff --git a/CLAUDE.md b/CLAUDE.md index 3d33852a..f93cad60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,6 +264,14 @@ deploy. Non-numeric (`String`/`DateTime`/`Reference`) data types are skipped (no recorder likewise drops + meters non-numeric values. Continuous historization is **numeric-analog only** in v1 (`UInt16→UInt4` is a documented fallback). +The provisioner is registered by the Host's `AddHistorianProvisioning` (`Runtime.ServiceCollectionExtensions`), +built via `GatewayHistorian.CreateProvisioner`, and **gated on the same `ServerHistorian:Enabled` flag as the +read path** — `WithOtOpcUaRuntimeActors` resolves `IHistorianProvisioning` and passes it into the applier. +When the section is disabled the applier binds the no-op `NullHistorianProvisioning` and provisioning does not +run. Every dispatch logs a tally (`dispatched/requested/ensured/skipped/failed`); `dispatched=N` with +`requested=0` flags the dormant no-op. The API key must carry `historian:tags:write` for `EnsureTags`. (PR +#423 shipped `GatewayTagProvisioner` but left this wiring out, so provisioning was dormant; restored 2026-06-27.) + ### Gateway-side prerequisites The target HistorianGateway OtOpcUa points at **must** run with: diff --git a/docs/Historian.md b/docs/Historian.md index cdb0c33c..e97e9ba3 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -82,7 +82,7 @@ and all HistoryRead calls on historized nodes return `GoodNoData` (empty, not an | `UseTls` | bool | `true` | Connect over TLS; must match the `Endpoint` scheme. | | `AllowUntrustedServerCertificate` | bool | `false` | Accept a self-signed / untrusted server certificate (dev / on-prem only). | | `CaCertificatePath` | string\|null | `null` | PEM CA file pinning the gateway's TLS chain. Null/empty uses the OS trust store. | -| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. | +| `CallTimeout` | TimeSpan | `00:00:30` | Per-call deadline applied to each unary gateway read. A read for a tag the historian does not yet know about — e.g. a freshly-historized tag that has **not** been provisioned (see [Tag auto-provisioning](#tag-auto-provisioning-ensuretags)) — can run to this **full** deadline before the gateway errors, so an unprovisioned-tag HistoryRead can block for ~30 s. Lower this to fail faster, at the cost of truncating legitimately long reads. | | `MaxTieClusterOverfetch` | int | `65536` | Maximum samples the server will fetch in one shot to page through a tie cluster (multiple samples sharing one `SourceTimestamp`). A cluster larger than this ceiling fails `BadHistoryOperationUnsupported`. Raise to handle abnormally large tie clusters; the default covers all normal-data cases. | > **Do not commit `ApiKey` to `appsettings.json`.** Set it via the environment variable @@ -104,6 +104,33 @@ source it from there. --- +## Tag auto-provisioning (EnsureTags) + +When a deploy adds a historized tag, the server auto-ensures it exists in the historian via the gateway's +`EnsureTags` **before** the first value or HistoryRead lands. `AddressSpaceApplier.Apply()` fires a +**non-blocking, fire-and-forget** `IHistorianProvisioning.EnsureTagsAsync` for each added historized value +tag (with the resolved historian name — the `historianTagname` override when set, else the driver +`FullName`); the hook is wrapped so a faulted, slow, or throwing provisioner can **never** block or fail a +deploy. Non-numeric (`String`/`DateTime`/`Reference`) types are skipped, not provisioned. + +**Wiring + gating.** The concrete provisioner is the gateway-backed `GatewayTagProvisioner`, registered by +the Host's `AddHistorianProvisioning` (`GatewayHistorian.CreateProvisioner`) and gated on the **same** +`ServerHistorian:Enabled` flag as the read path — both target the same single gateway. When the section is +disabled (or absent) the applier resolves the no-op `NullHistorianProvisioning` and **provisioning does not +run** (so on a read-only-historian deployment, ensure tags exist by some other means). The Host requires the +API key to carry the `historian:tags:write` scope for `EnsureTags` to succeed. + +**Observability.** Every dispatch logs an Information tally — +`historian provisioning completed (dispatched=N, requested=…, ensured=…, skipped=…, failed=…)`. A line +showing `dispatched=N` but `requested=0` indicates the **no-op** provisioner ran (provisioning is not wired +for this node), which is the signal to check that `ServerHistorian:Enabled=true`. + +> **Why this matters for HistoryRead latency.** Until a tag is provisioned, the historian does not know it, +> so a HistoryRead on it runs to the full `CallTimeout` (~30 s) before erroring. Auto-provisioning on deploy +> keeps freshly-historized tags from hitting that slow path. See the `CallTimeout` row above. + +--- + ## HistoryRead behavior ### Read variants diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs index 4acd5b88..6033bde7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianServiceCollectionExtensions.cs @@ -78,4 +78,40 @@ public static class GatewayHistorian HistorianGatewayClientAdapter.Create(options, loggerFactory), logger); } + + /// + /// Builds a over a lazily connected + /// mapped from the bound + /// — the same single gateway the read path + /// () and alarm-write path () target. The + /// Host's AddHistorianProvisioning wiring supplies this as the concrete + /// the AddressSpaceApplier calls (non-blocking + /// EnsureTags) when a deploy adds historized tags, so a brand-new historized tag exists in the + /// historian before the recorder's WriteLiveValues land. Resolves an + /// and the provisioner's from , falling + /// back to the null implementations when absent. Performs no network I/O — the underlying channel dials + /// on first EnsureTags. + /// + /// + /// Like , this constructs its own + /// — a separate gRPC channel to the same gateway. A second + /// channel to a co-located sidecar is cheap (the gateway pools the historian sessions server-side), and + /// it keeps each path's channel lifetime clean and independent. + /// + /// The bound ServerHistorian configuration (endpoint, key, TLS posture). + /// The resolving service provider (used only to locate logging services). + /// The gateway-backed . + public static IHistorianProvisioning CreateProvisioner(ServerHistorianOptions options, IServiceProvider services) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(services); + + var loggerFactory = services.GetService() ?? NullLoggerFactory.Instance; + var logger = services.GetService>() + ?? NullLogger.Instance; + + return new GatewayTagProvisioner( + HistorianGatewayClientAdapter.Create(options, loggerFactory), + logger); + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index a3e6bfee..eb083b0b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -72,6 +72,16 @@ if (roleSuffix is not null) builder.Configuration.AddCommandLine(args); } +// Anchor the process working directory to the install directory (AppContext.BaseDirectory) so every +// relative runtime path resolves under the install dir rather than the service's startup CWD. A Windows +// service starts with CWD=C:\Windows\System32, which otherwise scattered the Serilog rolling-file sink +// (appsettings "logs/otopcua-.log") into System32 and made on-box diagnosis awkward; the same anchor also +// roots the script log, the historization outbox, and any other relative artifact path. Done AFTER +// WebApplication.CreateBuilder (which has already resolved the content root + appsettings.json) and BEFORE +// AddZbSerilog (which instantiates the file sink), so only path resolution moves — config loading is +// untouched. UseWindowsService already roots the content root at the base dir, so this only affects sinks. +Directory.SetCurrentDirectory(AppContext.BaseDirectory); + // Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file) // now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the // shared NodeHostname / TraceContext / Redaction enrichers and trace correlation. @@ -125,6 +135,17 @@ if (hasDriver) builder.Configuration, (opts, sp) => GatewayHistorian.CreateDataSource(opts, sp)); + // Config-gated historian tag provisioning. When the ServerHistorian section is enabled this registers the + // gateway-backed GatewayTagProvisioner (overriding the NullHistorianProvisioning default) so that deploying + // a historized tag auto-ensures it in the historian via the gateway's EnsureTags. The AddressSpaceApplier + // (constructed in WithOtOpcUaRuntimeActors) resolves IHistorianProvisioning and fires a non-blocking + // EnsureTagsAsync for added historized tags on every deploy; without this registration the applier falls + // back to the no-op NullHistorianProvisioning and provisioning never reaches the gateway. Gated on the SAME + // ServerHistorian:Enabled flag as the read path — both target the same single gateway. + builder.Services.AddHistorianProvisioning( + builder.Configuration, + (opts, sp) => GatewayHistorian.CreateProvisioner(opts, sp)); + // Continuous historization of driver (non-Galaxy) tag values. Gated on ContinuousHistorization:Enabled // AND the ServerHistorian gateway being configured: the recorder drains driver-tag live values to the // SAME single gateway's WriteLiveValues SQL path, sourcing endpoint/key/TLS from the ServerHistorian diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index b7d636bb..4c97c570 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -271,13 +271,15 @@ public sealed class AddressSpaceApplier return; } + // Emit a tally on EVERY successful dispatch (not only on Failed/Skipped) so a fully- + // successful provisioning is observable AND a dormant no-op is detectable: the no-op + // NullHistorianProvisioning reports Requested=0 regardless of input, so a line showing + // dispatched={N} but requested=0 unmistakably flags "provisioning is not wired" — the + // exact invisibility that let the dormant-provisioner bug ship unnoticed. var result = t.Result; - if (result.Failed > 0 || result.Skipped > 0) - { - _logger.LogInformation( - "AddressSpaceApplier: historian provisioning completed (requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})", - result.Requested, result.Ensured, result.Skipped, result.Failed); - } + _logger.LogInformation( + "AddressSpaceApplier: historian provisioning completed (dispatched={Dispatched}, requested={Requested}, ensured={Ensured}, skipped={Skipped}, failed={Failed})", + provisionCount, result.Requested, result.Ensured, result.Skipped, result.Failed); }, CancellationToken.None, TaskContinuationOptions.None, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index fb60a6bd..9d0d6cf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -47,6 +47,10 @@ public static class ServiceCollectionExtensions { services.TryAddSingleton(NullAlarmHistorianSink.Instance); services.TryAddSingleton(NullHistorianDataSource.Instance); + // Historian tag provisioning. Null default (no-op) so the AddressSpaceApplier resolves a real + // IHistorianProvisioning only when AddHistorianProvisioning registers the gateway-backed one + // (ServerHistorian:Enabled). TryAddSingleton so the gateway registration wins last. + services.TryAddSingleton(NullHistorianProvisioning.Instance); // VirtualTag historization sink. Null default — the durable AVEVA sink is infra-gated (there is // no live-data historian write RPC). TryAddSingleton so a deployment that bound a real // IHistoryWriter earlier wins. @@ -135,6 +139,39 @@ public static class ServiceCollectionExtensions return services; } + /// + /// Config-gated historian tag provisioning. When the ServerHistorian section has + /// Enabled=true, registers the -supplied + /// (the gateway-backed GatewayTagProvisioner that calls + /// the gateway's EnsureTags) overriding the default from + /// . Otherwise a no-op (the Null default stays and deploying historized + /// tags provisions nothing). The provisioner is consumed by the AddressSpaceApplier, which fires + /// a non-blocking for added historized tags on + /// every deploy. Gated on the same ServerHistorian:Enabled flag as the read path + /// () — provisioning targets the same single gateway. The provisioner + /// is injected so the gateway-backed client can be supplied by the Host, which is the only project that + /// references the driver. + /// + /// The service collection to register with. + /// The configuration carrying the ServerHistorian section. + /// + /// Factory the Host supplies to build the concrete + /// (the gateway-backed provisioner) from the bound options + the resolving provider. + /// + /// The same instance for chaining. + public static IServiceCollection AddHistorianProvisioning( + this IServiceCollection services, + IConfiguration configuration, + Func provisioningFactory) + { + var opts = configuration.GetSection(ServerHistorianOptions.SectionName).Get(); + if (opts is not { Enabled: true }) return services; // leave the Null default from AddOtOpcUaRuntime + + // Last-registration-wins over the TryAddSingleton Null default seeded by AddOtOpcUaRuntime. + services.AddSingleton(sp => provisioningFactory(opts, sp)); + return services; + } + /// /// Spawns the per-node driver-role actors on the host's : /// (one per node), @@ -252,12 +289,19 @@ public static class ServiceCollectionExtensions ? new ActorHistorizedTagSubscriptionSink(continuousRecorder) : NullHistorizedTagSubscriptionSink.Instance; + // Historian tag provisioner fed to the applier so deploying historized tags auto-ensures + // them in the historian (EnsureTags). The Host registers the gateway-backed provisioner via + // AddHistorianProvisioning when ServerHistorian:Enabled; otherwise this resolves the no-op + // NullHistorianProvisioning default seeded by AddOtOpcUaRuntime (so the applier's hook is inert). + var provisioning = resolver.GetService(); + // OPC UA publish actor — pinned dispatcher, owns the address-space side of the // pipeline. AddressSpaceApplier is constructed here so the actor + applier share the // same sink reference (when DeferredAddressSpaceSink swaps later, both see it). var applier = new AddressSpaceApplier( addressSpaceSink, loggerFactory.CreateLogger(), + provisioning: provisioning, historizedSubscriptions: historizedSubscriptions); var publishActor = system.ActorOf( OpcUaPublishActor.Props( diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs index 02b60e5f..0e753aac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierProvisioningTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; @@ -143,6 +144,77 @@ public sealed class AddressSpaceApplierProvisioningTests prov.Seen[0].TagName.ShouldBe("Pump1.Good"); } + /// Capturing that records every log line and signals a + /// the first time a "historian provisioning completed" tally is + /// emitted, so the fire-and-forget continuation's log can be awaited deterministically. + private sealed class CapturingLogger : ILogger + { + private readonly TaskCompletionSource _provisioningLogged = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// Completes with the rendered message of the first provisioning-completed tally logged. + public Task ProvisioningLogged => _provisioningLogged.Task; + + public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + var message = formatter(state, exception); + if (logLevel == LogLevel.Information && + message.Contains("historian provisioning completed", StringComparison.Ordinal)) + { + _provisioningLogged.TrySetResult(message); + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + public void Dispose() { } + } + } + + /// Issue 2 (observability): a fully-successful provisioning (Ensured>0, Failed=0, Skipped=0) — the + /// case that previously logged NOTHING — now emits an Information tally, so a successful provisioning is + /// observable rather than silent. + [Fact] + public async Task Apply_logs_an_information_tally_on_a_fully_successful_provisioning() + { + var logger = new CapturingLogger(); + var applier = new AddressSpaceApplier( + NullOpcUaAddressSpaceSink.Instance, logger, new CapturingProvisioner()); + + applier.Apply(PlanWithAddedTags( + HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); + + var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + message.ShouldContain("dispatched=1"); + message.ShouldContain("ensured=1"); + message.ShouldContain("failed=0"); + } + + /// Issue 2 (observability): the no-op path is now + /// DETECTABLE — it reports Requested=0 regardless of input, so a tally showing dispatched=1 but requested=0 + /// unmistakably flags "provisioning is not wired", the invisibility that let the dormant-provisioner bug + /// ship unnoticed. + [Fact] + public async Task Apply_logs_a_detectable_tally_for_the_noop_provisioning_path() + { + var logger = new CapturingLogger(); + var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, logger); + + applier.Apply(PlanWithAddedTags( + HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); + + var message = await logger.ProvisioningLogged.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + message.ShouldContain("dispatched=1"); // we DID hand one request to EnsureTagsAsync + message.ShouldContain("requested=0"); // ...but the no-op acknowledges nothing ⇒ dormant + } + /// Capturing double. Records the add/remove /// ref deltas the applier feeds it. A flag simulates a faulting feed. private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs new file mode 100644 index 00000000..32220a41 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/AddHistorianProvisioningTests.cs @@ -0,0 +1,199 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; + +/// +/// Verifies the config-gated AddHistorianProvisioning registration AND that a registered +/// provisioner is actually reached by the address-space applier on a historized-tag deploy. This is the +/// wiring the HistorianGateway-integration review found missing: PR #423 shipped +/// GatewayTagProvisioner + unit tests but never registered it in DI nor passed it into the applier, +/// so EnsureTags was dormant (every deploy used the no-op ). +/// +/// When the ServerHistorian section is absent or disabled the +/// default seeded by AddOtOpcUaRuntime survives (the factory is never invoked); when it is enabled +/// the factory's returned wins (last-registration-wins over the +/// TryAddSingleton Null default) and — constructed into the applier the way +/// WithOtOpcUaRuntimeActors does — its fires +/// for an added historized tag. +/// +/// +public sealed class AddHistorianProvisioningTests +{ + /// Capturing double — records the requests it was handed and + /// signals a when invoked so the fire-and-forget applier dispatch can be + /// awaited deterministically. + private sealed class CapturingProvisioner : IHistorianProvisioning + { + private readonly TaskCompletionSource _called = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// The requests the applier handed to . + public List Seen { get; } = new(); + + /// Completes once has been invoked. + public Task Called => _called.Task; + + /// + public Task EnsureTagsAsync( + IReadOnlyList requests, CancellationToken ct) + { + Seen.AddRange(requests); + _called.TrySetResult(); + return Task.FromResult(new HistorianProvisionResult(requests.Count, requests.Count, 0, 0)); + } + } + + private static IConfiguration ConfigFrom(Dictionary values) + => new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + private static IConfiguration EnabledConfig() => ConfigFrom(new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", + }); + + [Fact] + public void Section_absent_keeps_null_provisioning_and_factory_not_invoked() + { + var factoryInvoked = false; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + + services.AddHistorianProvisioning(ConfigFrom(new Dictionary()), (_, _) => + { + factoryInvoked = true; + return new CapturingProvisioner(); + }); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance); + factoryInvoked.ShouldBeFalse(); + } + + [Fact] + public void Section_disabled_keeps_null_provisioning_and_factory_not_invoked() + { + var factoryInvoked = false; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary { ["ServerHistorian:Enabled"] = "false" }); + + services.AddHistorianProvisioning(config, (_, _) => + { + factoryInvoked = true; + return new CapturingProvisioner(); + }); + + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService().ShouldBeSameAs(NullHistorianProvisioning.Instance); + factoryInvoked.ShouldBeFalse(); + } + + [Fact] + public void Section_enabled_registers_factory_provisioner_over_the_null_default() + { + var prov = new CapturingProvisioner(); + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + + services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov); + + using var provider = services.BuildServiceProvider(); + var resolved = provider.GetRequiredService(); + resolved.ShouldBeSameAs(prov); + resolved.ShouldNotBeSameAs(NullHistorianProvisioning.Instance); + } + + [Fact] + public void Section_enabled_passes_bound_options_to_factory() + { + ServerHistorianOptions? seen = null; + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + var config = ConfigFrom(new Dictionary + { + ["ServerHistorian:Enabled"] = "true", + ["ServerHistorian:Endpoint"] = "https://historian.example.com:5222", + ["ServerHistorian:ApiKey"] = "histgw_x_y", + ["ServerHistorian:UseTls"] = "true", + ["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem", + }); + + services.AddHistorianProvisioning(config, (opts, _) => + { + seen = opts; + return new CapturingProvisioner(); + }); + + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService(); + + seen.ShouldNotBeNull(); + seen.Endpoint.ShouldBe("https://historian.example.com:5222"); + seen.ApiKey.ShouldBe("histgw_x_y"); + seen.UseTls.ShouldBeTrue(); + seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem"); + } + + /// + /// The end-to-end wiring proof the dormant-provisioner bug needed: register the provisioner via + /// AddHistorianProvisioning, resolve it, construct the EXACTLY + /// as WithOtOpcUaRuntimeActors does (provisioning: resolver.GetService<IHistorianProvisioning>()), + /// then apply a plan that adds a historized tag and assert the gateway-backed + /// actually fires (with the resolved historian name). + /// + [Fact] + public async Task Registered_provisioner_is_invoked_by_the_applier_on_a_historized_deploy() + { + var prov = new CapturingProvisioner(); + var services = new ServiceCollection(); + services.AddOtOpcUaRuntime(); + services.AddHistorianProvisioning(EnabledConfig(), (_, _) => prov); + using var provider = services.BuildServiceProvider(); + + // Mirror the production construction in WithOtOpcUaRuntimeActors: resolve the provisioner and pass it in. + var resolved = provider.GetService(); + var applier = new AddressSpaceApplier( + NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, provisioning: resolved); + + applier.Apply(PlanWithAddedTags( + HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"), + NonHistorizedTag(displayName: "Run", dataType: "Boolean"))); + + await prov.Called.WaitAsync(TimeSpan.FromSeconds(5)); + prov.Seen.Count.ShouldBe(1); + prov.Seen[0].TagName.ShouldBe("Pump1.Temp"); + prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32); + } + + private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") + => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, + Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); + + private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) + => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", + Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); + + private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( + AddedEquipment: Array.Empty(), + RemovedEquipment: Array.Empty(), + ChangedEquipment: Array.Empty(), + AddedDrivers: Array.Empty(), + RemovedDrivers: Array.Empty(), + ChangedDrivers: Array.Empty(), + AddedAlarms: Array.Empty(), + RemovedAlarms: Array.Empty(), + ChangedAlarms: Array.Empty()) + { + AddedEquipmentTags = tags, + }; +}