feat(abcip): final review I1 — node-level Drivers:AbCip:DefaultTransport

The old AdminUI cannot author the per-driver transport key (#576), so the
'pin before upgrading' recipe had no working path. A node-level appsetting
(env Drivers__AbCip__DefaultTransport) now supplies the default for AbCip
drivers with no key: explicit key > node default > compiled Native. Bound
and validated unconditionally (bad value fails host start); the driver
factory and the Test Connect probe both read it so they agree. AbCip.md
upgrade recipe rewritten around the key; Configuration.md documents it.
Also documents first-wins for AllowPacking/probe and host-alias throttles
(final review M6) and moves the status-bit note out of AbCip.md (M5).

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-07 21:34:48 -04:00
parent b66972b6f5
commit b75e013d0a
13 changed files with 447 additions and 44 deletions
@@ -0,0 +1,153 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration;
/// <summary>
/// Final review I1 — the node-level <c>Drivers:AbCip:DefaultTransport</c>: binding (appsettings and the
/// <c>__</c> env-var form), precedence (explicit per-driver key &gt; node default &gt; compiled Native), the
/// probe agreeing with the driver factory, and fail-fast validation. Every precedence test runs a pinned
/// node (Libplctag) against the compiled default (Native), so a wiring that ignores the node value goes red.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDriverDefaultsOptionsTests
{
private const string NoKey = """{"devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
private const string ExplicitNative =
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.1/1,0"}],"probe":{"enabled":false}}""";
private static ServiceProvider Build(IConfiguration configuration)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISecretResolver>(new StubSecretResolver());
services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
configuration, AbCipDriverDefaultsOptions.SectionName);
services.AddOtOpcUaDriverFactories();
return services.BuildServiceProvider();
}
private static IConfiguration Config(string? value) =>
new ConfigurationBuilder()
.AddInMemoryCollection(value is null
? []
: new Dictionary<string, string?> { ["Drivers:AbCip:DefaultTransport"] = value })
.Build();
private static AbCipDriver Create(ServiceProvider sp, string json) =>
(AbCipDriver)sp.GetRequiredService<DriverFactoryRegistry>().TryGet(DriverTypeNames.AbCip)!("drv-i1", json);
[Fact]
public void Absent_section_defaults_to_Native()
{
using var sp = Build(Config(null));
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
.ShouldBe(AbCipTransport.Native);
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Native);
}
[Theory]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
[InlineData("libplctag", AbCipTransport.Libplctag)]
[InlineData("NATIVE", AbCipTransport.Native)]
[InlineData("", AbCipTransport.Native)]
public void The_appsettings_key_binds(string value, AbCipTransport expected)
{
using var sp = Build(Config(value));
sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value.ResolveDefaultTransport()
.ShouldBe(expected);
}
/// <summary>The documented env-var override form, <c>Drivers__AbCip__DefaultTransport</c>.</summary>
[Fact]
public void The_double_underscore_environment_variable_binds()
{
const string prefix = "OTOPCUA_I1_TEST_";
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", "Libplctag");
try
{
var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build();
using var sp = Build(configuration);
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
}
finally
{
Environment.SetEnvironmentVariable(prefix + "Drivers__AbCip__DefaultTransport", null);
}
}
/// <summary>A pinned node moves no-key drivers; an explicit per-driver key still wins over the pin.</summary>
[Fact]
public void Node_default_applies_only_to_drivers_without_a_key()
{
using var sp = Build(Config("Libplctag"));
Create(sp, NoKey).EffectiveTransport.ShouldBe(AbCipTransport.Libplctag);
Create(sp, ExplicitNative).EffectiveTransport.ShouldBe(AbCipTransport.Native);
}
/// <summary>The Test Connect probe resolves an absent key to the SAME node default the driver does.</summary>
[Theory]
[InlineData("Libplctag", AbCipTransport.Libplctag)]
[InlineData(null, AbCipTransport.Native)]
public void The_probe_uses_the_node_default(string? value, AbCipTransport expected)
{
using var sp = Build(Config(value));
var probe = sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().ShouldHaveSingleItem();
probe.DefaultTransport.ShouldBe(expected);
Create(sp, NoKey).EffectiveTransport.ShouldBe(expected); // driver and probe agree
}
/// <summary>The probe stays registered exactly once on a fused node (driver path + admin path).</summary>
[Fact]
public void The_factory_built_probe_is_still_deduplicated()
{
var services = new ServiceCollection();
services.AddOtOpcUaDriverProbes();
services.AddOtOpcUaDriverProbes();
using var sp = services.BuildServiceProvider();
sp.GetServices<IDriverProbe>().OfType<AbCipDriverProbe>().Count().ShouldBe(1);
}
[Theory]
[InlineData("Bogus")]
[InlineData("1")]
[InlineData("7")]
[InlineData("Native,Libplctag")]
public void A_bad_value_fails_validation_with_a_named_message(string value)
{
var result = new AbCipDriverDefaultsOptionsValidator().Validate(
null, new AbCipDriverDefaultsOptions { DefaultTransport = value });
result.Failed.ShouldBeTrue();
result.Failures.ShouldContain(f => f.Contains("Drivers:AbCip:DefaultTransport") && f.Contains(value));
using var sp = Build(Config(value));
Should.Throw<OptionsValidationException>(
() => sp.GetRequiredService<IOptions<AbCipDriverDefaultsOptions>>().Value)
.Message.ShouldContain("Drivers:AbCip:DefaultTransport");
}
[Theory]
[InlineData("Native")]
[InlineData("libplctag")]
[InlineData(null)]
[InlineData(" ")]
public void A_good_or_blank_value_validates(string? value) =>
new AbCipDriverDefaultsOptionsValidator()
.Validate(null, new AbCipDriverDefaultsOptions { DefaultTransport = value })
.Succeeded.ShouldBeTrue();
private sealed class StubSecretResolver : ISecretResolver
{
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
}