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

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:
Joseph Doherty
2026-11-11 00:27:37 -05:00
parent 7af9c40e41
commit 5bbe1e3c7a
8 changed files with 60 additions and 17 deletions
@@ -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:&lt;name&gt;</span> resolves from the node's secret store, <span class="mono">env:&lt;VAR&gt;</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/&lt;name&gt;</span> resolves from the node's secret store, <span class="mono">env:OTOPCUA_HEIDENHAIN_&lt;NAME&gt;</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>
@@ -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);
}
@@ -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:&lt;name&gt;</span> (node secret store) or <span class="mono">env:&lt;VAR&gt;</span>. Resolved at each login; a literal password is refused.</div>
<div class="form-text">References only: blank, <span class="mono">secret:heidenhain/&lt;name&gt;</span> (node secret store) or <span class="mono">env:OTOPCUA_HEIDENHAIN_&lt;NAME&gt;</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;
}