fix(heidenhain): resolve the probe's secret resolver lazily — eager resolution crash-looped central nodes at boot (#803)
v2-ci / unit-tests (push) Successful in 1h9m7s
v2-ci / unit-tests (push) Successful in 1h9m7s
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.
This commit is contained in:
+1
-1
@@ -65,7 +65,7 @@ public sealed class HeidenhainDeviceOptions
|
||||
|
||||
/// <summary>
|
||||
/// Access settings. Password fields are <b>references</b>, never literals: blank (no password),
|
||||
/// <c>secret:<name></c> (the node's encrypted secret store) or <c>env:<VARIABLE></c>. A literal is
|
||||
/// <c>secret:heidenhain/<name></c> (the node's encrypted secret store) or <c>env:OTOPCUA_HEIDENHAIN_<NAME></c>. A literal is
|
||||
/// refused at parse and at the deploy gate, so a controller password never lands in the config database.
|
||||
/// </summary>
|
||||
public sealed class HeidenhainAccessOptions
|
||||
|
||||
@@ -13,9 +13,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
|
||||
/// <remarks>
|
||||
/// Registered on admin nodes as well as driver nodes (the Test-Connect singleton is admin-pinned). The
|
||||
/// resolver is optional: without one a <c>secret:</c> INSPECT password fails closed with a clear message.
|
||||
/// <para>
|
||||
/// The host registers the probe with a resolver <em>accessor</em>, never a resolved instance: the probe set is
|
||||
/// resolved inside the AdminOperations Akka starter, and resolving <see cref="ISecretResolver" /> there walks
|
||||
/// <c>AddZbSecrets</c> → <c>IAuditWriter</c> → <c>ActorAuditWriter</c>, whose constructor needs the
|
||||
/// <c>AuditWriterActorKey</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HeidenhainDriverProbe(ISecretResolver? secretResolver = null) : IDriverProbe
|
||||
public sealed class HeidenhainDriverProbe : IDriverProbe
|
||||
{
|
||||
private readonly Func<ISecretResolver?> _secretResolver;
|
||||
|
||||
/// <summary>Creates a probe with a fixed (optional) resolver — for tests and graphs without the Akka audit writer.</summary>
|
||||
public HeidenhainDriverProbe(ISecretResolver? secretResolver = null) => _secretResolver = () => secretResolver;
|
||||
|
||||
/// <summary>Creates a probe that resolves the secret resolver lazily, once per probe call.</summary>
|
||||
public HeidenhainDriverProbe(Func<ISecretResolver?> secretResolverAccessor) =>
|
||||
_secretResolver = secretResolverAccessor ?? throw new ArgumentNullException(nameof(secretResolverAccessor));
|
||||
|
||||
/// <inheritdoc />
|
||||
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); }
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
<InputText id="hhData" @bind-Value="_model.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Blank = use the driver-level reference. <span class="mono">secret:<name></span> resolves from the node's secret store, <span class="mono">env:<VAR></span> from the environment, at each login. A literal password is never stored.</div>
|
||||
<div class="form-text">Blank = use the driver-level reference. <span class="mono">secret:heidenhain/<name></span> resolves from the node's secret store, <span class="mono">env:OTOPCUA_HEIDENHAIN_<NAME></span> from the environment, at each login. A literal password is never stored.</div>
|
||||
@if (_model.Validate() is { } error)
|
||||
{
|
||||
<div class="text-danger small mt-2">@error</div>
|
||||
|
||||
+1
-1
@@ -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:<name> or env:<VARIABLE> — never a literal.");
|
||||
errors.Add($"{name} password must be blank, secret:heidenhain/<name> or env:OTOPCUA_HEIDENHAIN_<NAME> — never a literal.");
|
||||
return errors.Count == 0 ? null : string.Join(" ", errors);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@
|
||||
<InputText id="hhDataPw" @bind-Value="_m.DataPassword" @bind-Value:after="EmitAsync" class="form-control form-control-sm mono" placeholder="env:OTOPCUA_HEIDENHAIN_DATA_PASSWORD" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">References only: blank, <span class="mono">secret:<name></span> (node secret store) or <span class="mono">env:<VAR></span>. Resolved at each login; a literal password is refused.</div>
|
||||
<div class="form-text">References only: blank, <span class="mono">secret:heidenhain/<name></span> (node secret store) or <span class="mono">env:OTOPCUA_HEIDENHAIN_<NAME></span>. Resolved at each login; a literal password is refused.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1271,6 +1271,16 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
(dev, drv) => drv.DriverType)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The driver-typed <c>TagConfig</c> 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<OtOpcUaConfigDbContext> dbF
|
||||
/// <param name="tagConfig">The normalised TagConfig JSON about to be persisted.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>An operator-facing error, or <see langword="null"/> when the config is acceptable.</returns>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string?> ValidateTagConfigAsync(
|
||||
OtOpcUaConfigDbContext db, string deviceId, string tagConfig, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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<IDriverProbe, HeidenhainProbe>(
|
||||
sp => new HeidenhainProbe(sp.GetService<ISecretResolver>())));
|
||||
sp => new HeidenhainProbe(() => sp.GetService<ISecretResolver>())));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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<ZB.MOM.WW.Secrets.Abstractions.ISecretResolver>(_ =>
|
||||
{
|
||||
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<IDriverProbe>().ToList();
|
||||
|
||||
probes.ShouldNotBeEmpty();
|
||||
resolved.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user