From 5bbe1e3c7adcca0d9286933cae4a73166ee0470d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 00:27:37 -0500 Subject: [PATCH] =?UTF-8?q?fix(heidenhain):=20resolve=20the=20probe's=20se?= =?UTF-8?q?cret=20resolver=20lazily=20=E2=80=94=20eager=20resolution=20cra?= =?UTF-8?q?sh-looped=20central=20nodes=20at=20boot=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Test-Connect probe set is resolved inside the AdminOperations Akka starter; resolving ISecretResolver there walks AddZbSecrets -> IAuditWriter -> ActorAuditWriter, whose ctor needs AuditWriterActorKey (registered later in the same builder). Found by the docker-dev live gate. Regression test: resolving the probe set never resolves ISecretResolver (fails before the fix). Also: TagRowProblem doc comment re-attached; Heidenhain credential help text names the secret:heidenhain/ and env:OTOPCUA_HEIDENHAIN_ namespaces. --- .../HeidenhainDriverOptions.cs | 2 +- .../HeidenhainDriverProbe.cs | 20 ++++++++++++++-- .../DeviceForms/HeidenhainDeviceForm.razor | 2 +- .../DeviceForms/HeidenhainDeviceModel.cs | 2 +- .../Drivers/Forms/HeidenhainDriverForm.razor | 2 +- .../Uns/RawTreeService.cs | 20 ++++++++-------- .../Drivers/DriverFactoryBootstrap.cs | 6 ++++- .../DriverProbeRegistrationTests.cs | 23 +++++++++++++++++++ 8 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs index 157b171dd..880505ad2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Contracts/HeidenhainDriverOptions.cs @@ -65,7 +65,7 @@ public sealed class HeidenhainDeviceOptions /// /// Access settings. Password fields are references, never literals: blank (no password), -/// secret:<name> (the node's encrypted secret store) or env:<VARIABLE>. A literal is +/// secret:heidenhain/<name> (the node's encrypted secret store) or env:OTOPCUA_HEIDENHAIN_<NAME>. A literal is /// refused at parse and at the deploy gate, so a controller password never lands in the config database. /// public sealed class HeidenhainAccessOptions diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs index 44f9c3785..fabd31043 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Heidenhain/HeidenhainDriverProbe.cs @@ -13,9 +13,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain; /// /// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The /// resolver is optional: without one a secret: INSPECT password fails closed with a clear message. +/// +/// The host registers the probe with a resolver accessor, never a resolved instance: the probe set is +/// resolved inside the AdminOperations Akka starter, and resolving there walks +/// AddZbSecrets → IAuditWriter → ActorAuditWriter, whose constructor needs the +/// AuditWriterActorKey registered later in the same builder — every central node failed to boot (#803 +/// live gate). The accessor runs per probe call, after the host is built. +/// /// -public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe +public sealed class HeidenhainDriverProbe : IDriverProbe { + private readonly Func _secretResolver; + + /// Creates a probe with a fixed (optional) resolver — for tests and graphs without the Akka audit writer. + public HeidenhainDriverProbe(ISecretResolver? secretResolver = null) => _secretResolver = () => secretResolver; + + /// Creates a probe that resolves the secret resolver lazily, once per probe call. + public HeidenhainDriverProbe(Func secretResolverAccessor) => + _secretResolver = secretResolverAccessor ?? throw new ArgumentNullException(nameof(secretResolverAccessor)); + /// public string DriverType => DriverTypeNames.Heidenhain; @@ -42,7 +58,7 @@ public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null string password; // Resolved only through the namespaced resolver (fail-closed at resolve time): Test Connect can never be // used to send another subsystem's secret or environment variable to an arbitrary Host. - try { password = await new HeidenhainCredentials(secretResolver).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); } + try { password = await new HeidenhainCredentials(_secretResolver()).ResolveAsync(options, device, "INSPECT", cts.Token).ConfigureAwait(false); } catch (HeidenhainCredentialException ex) { return new(false, ex.Message, null); } try { _ = Lsv2ReadOnlyPolicy.LoginPayload("INSPECT", password); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor index ec8829186..aa0643451 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceForm.razor @@ -38,7 +38,7 @@ -
Blank = use the driver-level reference. secret:<name> resolves from the node's secret store, env:<VAR> from the environment, at each login. A literal password is never stored.
+
Blank = use the driver-level reference. secret:heidenhain/<name> resolves from the node's secret store, env:OTOPCUA_HEIDENHAIN_<NAME> from the environment, at each login. A literal password is never stored.
@if (_model.Validate() is { } error) {
@error
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs index fe6ae03d6..4d9b43db2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DeviceForms/HeidenhainDeviceModel.cs @@ -47,7 +47,7 @@ public sealed class HeidenhainDeviceModel var errors = HeidenhainOptionsParser.ValidateDevice(new HeidenhainDeviceOptions { Host = Host.Trim(), Port = Port }).ToList(); foreach (var (name, v) in new[] { ("INSPECT", InspectPassword), ("DNC", DncPassword), ("DATA", DataPassword) }) if (!HeidenhainOptionsParser.IsCredentialReference(Blank(v))) - errors.Add($"{name} password must be blank, secret: or env: — never a literal."); + errors.Add($"{name} password must be blank, secret:heidenhain/ or env:OTOPCUA_HEIDENHAIN_ — never a literal."); return errors.Count == 0 ? null : string.Join(" ", errors); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor index ee0e6b005..96faaa293 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/HeidenhainDriverForm.razor @@ -70,7 +70,7 @@ -
References only: blank, secret:<name> (node secret store) or env:<VAR>. Resolved at each login; a literal password is refused.
+
References only: blank, secret:heidenhain/<name> (node secret store) or env:OTOPCUA_HEIDENHAIN_<NAME>. Resolved at each login; a literal password is refused.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs index 74172dfd7..148a37a39 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTreeService.cs @@ -1271,6 +1271,16 @@ public sealed class RawTreeService(IDbContextFactory dbF (dev, drv) => drv.DriverType) .FirstOrDefaultAsync(ct); + /// + /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only + /// and carry the data type the driver publishes — the same rule the deploy gate applies. + /// + private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => + string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) + ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( + input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) + : null; + /// /// The driver-typed TagConfig gate, applied on the SERVER for every tag-writing path — /// create, update and bulk import alike. @@ -1293,16 +1303,6 @@ public sealed class RawTreeService(IDbContextFactory dbF /// The normalised TagConfig JSON about to be persisted. /// Cancellation token. /// An operator-facing error, or when the config is acceptable. - /// - /// Driver-specific Tag-ROW rules beyond the TagConfig (lmxopcua#803): a Heidenhain tag must be Read-only - /// and carry the data type the driver publishes — the same rule the deploy gate applies. - /// - private static string? TagRowProblem(string? driverType, RawTagInput input, string tagConfig) => - string.Equals(driverType, DriverTypeNames.Heidenhain, StringComparison.OrdinalIgnoreCase) - ? Driver.Heidenhain.HeidenhainTagRules.DescribeRowProblem( - input.AccessLevel == Configuration.Enums.TagAccessLevel.ReadWrite, input.DataType, tagConfig) - : null; - private static async Task ValidateTagConfigAsync( OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index a3f0fa480..9bb0686d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -199,8 +199,12 @@ public static class DriverFactoryBootstrap // Factory-built so the probe resolves secret: INSPECT password references through the node's resolver // (registered on every node by AddZbSecrets; optional so a test graph without one still builds — a // secret: ref then fails closed with a clear probe message rather than sending the literal). + // ⚠️ Captured as an accessor, NOT resolved here: the probe set is resolved inside the AdminOperations + // Akka starter, and ISecretResolver → AddZbSecrets → IAuditWriter → ActorAuditWriter needs the + // AuditWriterActorKey that is registered later in that builder — resolving it eagerly crash-looped every + // central node at boot (#803 live gate). See ControlPlane ServiceCollectionExtensions' WithActors note. services.TryAddEnumerable(ServiceDescriptor.Singleton( - sp => new HeidenhainProbe(sp.GetService()))); + sp => new HeidenhainProbe(() => sp.GetService()))); return services; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs index 085f6d312..f7fec3c7b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -102,4 +102,27 @@ public sealed class DriverProbeRegistrationTests probes.Count.ShouldBe(distinctTypes, "Duplicate IDriverProbe registrations — TryAddEnumerable should de-dup."); distinctTypes.ShouldBe(DriverTypeNames.All.Count); } + + [Fact] + public void Resolving_the_probe_set_never_resolves_the_secret_resolver() + { + // #803 live gate: the probe set is resolved inside the AdminOperations Akka starter, where + // ISecretResolver -> AddZbSecrets -> IAuditWriter -> ActorAuditWriter throws (its actor key is + // registered later in the same builder). A probe that resolved the secret resolver eagerly crash-looped + // every central node at boot. Model that starter-time graph with a resolver factory that throws. + var resolved = 0; + var services = new ServiceCollection(); + services.AddSingleton(_ => + { + resolved++; + throw new InvalidOperationException("ISecretResolver must not be resolved while building the probe set"); + }); + services.AddOtOpcUaDriverProbes(); + + using var sp = services.BuildServiceProvider(); + var probes = sp.GetServices().ToList(); + + probes.ShouldNotBeEmpty(); + resolved.ShouldBe(0); + } }