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
@@ -34,7 +34,8 @@ public sealed class AbCipDriverOptions
/// <summary>
/// Which transport stack the driver, its Test Connect probe and the CLI use. <c>null</c> (the
/// <c>transport</c> key absent) means "use the default", which is <see cref="AbCipTransport.Native"/>;
/// <c>transport</c> key absent) means "use the default": the node-level
/// <c>Drivers:AbCip:DefaultTransport</c> appsetting, which itself defaults to <see cref="AbCipTransport.Native"/>;
/// an explicit value always wins. The effective transport is resolved in one place,
/// <c>AbCipTransportResolver</c>. Set <see cref="AbCipTransport.Libplctag"/> to fall back.
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
@@ -141,19 +141,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="enumeratorFactory">Optional factory for enumerating tags; when null the resolved transport's is used.</param>
/// <param name="templateReaderFactory">Optional factory for reading UDT templates; when null the resolved transport's is used.</param>
/// <param name="logger">Optional logger; uses null logger if not provided.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key
/// (<c>Drivers:AbCip:DefaultTransport</c>); null means the compiled <see cref="AbCipTransportResolver.Default"/>.</param>
public AbCipDriver(AbCipDriverOptions options, string driverInstanceId,
IAbCipTagFactory? tagFactory = null,
IAbCipTagEnumeratorFactory? enumeratorFactory = null,
IAbCipTemplateReaderFactory? templateReaderFactory = null,
ILogger<AbCipDriver>? logger = null)
ILogger<AbCipDriver>? logger = null,
AbCipTransport? defaultTransport = null)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_driverInstanceId = driverInstanceId;
// Chosen once here and held across ReinitializeAsync (Shutdown+Initialize): a changed
// Transport in a reinit config is inert; a deploy recreates the instance instead.
_transport = AbCipTransportResolver.Resolve(options);
_transportDescription = AbCipTransportResolver.Describe(options.Transport);
var nodeDefault = defaultTransport ?? AbCipTransportResolver.Default;
_transport = AbCipTransportResolver.Resolve(options, nodeDefault);
_transportDescription = AbCipTransportResolver.Describe(options.Transport, nodeDefault);
var defaults = AbCipTransportFactories.For(_transport);
_tagFactory = tagFactory ?? defaults.Tags;
_enumeratorFactory = enumeratorFactory ?? defaults.Enumerators;
@@ -182,7 +186,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>The effective transport resolved at construction (see <see cref="AbCipTransportResolver"/>).
/// Describes the OPTION only: injected factories (tests) are used regardless.</summary>
internal AbCipTransport EffectiveTransport => _transport;
public AbCipTransport EffectiveTransport => _transport;
// The transport chosen at construction (see the ctor); logged at every init so an operator can tell
// from the node log which stack serves this instance.
@@ -24,10 +24,14 @@ public static class AbCipDriverFactoryExtensions
/// </summary>
/// <param name="registry">The driver factory registry to register with.</param>
/// <param name="loggerFactory">Optional logger factory for driver instances.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
/// <param name="defaultTransport">The node-level transport for drivers whose config has no
/// <c>transport</c> key (<c>Drivers:AbCip:DefaultTransport</c>, final review I1). An explicit
/// per-driver key still wins.</param>
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentNullException.ThrowIfNull(registry);
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, defaultTransport));
}
/// <summary>
@@ -43,18 +47,21 @@ public static class AbCipDriverFactoryExtensions
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
/// <param name="driverConfigJson">The driver configuration as a JSON string.</param>
/// <param name="loggerFactory">Optional logger factory for the driver instance.</param>
/// <param name="defaultTransport">The node-level default for an absent <c>transport</c> key.</param>
/// <returns>A configured <see cref="AbCipDriver"/> instance.</returns>
internal static AbCipDriver CreateInstance(
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
var options = ParseOptions(driverInstanceId, driverConfigJson);
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>());
return new AbCipDriver(options, driverInstanceId, logger: loggerFactory?.CreateLogger<AbCipDriver>(),
defaultTransport: defaultTransport);
}
/// <summary>
/// Deserialise an AB CIP driver-config JSON document into <see cref="AbCipDriverOptions"/>.
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?)"/> (first construction) and
/// Shared by <see cref="CreateInstance(string, string, ILoggerFactory?, AbCipTransport)"/> (first construction) and
/// <see cref="AbCipDriver.InitializeAsync"/> / <see cref="AbCipDriver.ReinitializeAsync"/>
/// so a reinitialize with a changed config JSON (new device, new tag, changed timeout)
/// actually takes effect rather than being silently discarded.
@@ -155,7 +162,7 @@ public static class AbCipDriverFactoryExtensions
internal sealed class AbCipDriverConfigDto
{
/// <summary>
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means Libplctag.
/// Gets or sets the transport (<c>Libplctag</c> or <c>Native</c>, case-insensitive); absent means the node default.
/// </summary>
public string? Transport { get; init; }
@@ -34,11 +34,22 @@ public sealed class AbCipDriverProbe : IDriverProbe
};
private readonly IAbCipTagFactory? _tagFactory;
private readonly AbCipTransport _defaultTransport;
/// <summary>Initializes a new instance of the <see cref="AbCipDriverProbe"/> class.</summary>
/// <param name="tagFactory">Factory for the probe's tag runtime; when null the config's
/// <c>Transport</c> selects the native or libplctag-backed one.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null) => _tagFactory = tagFactory;
/// <param name="defaultTransport">The node-level default for a config with no <c>transport</c> key — the
/// SAME value the node's driver factory uses, so Test Connect probes the stack the driver would run.</param>
public AbCipDriverProbe(IAbCipTagFactory? tagFactory = null,
AbCipTransport defaultTransport = AbCipTransportResolver.Default)
{
_tagFactory = tagFactory;
_defaultTransport = defaultTransport;
}
/// <summary>The node-level default this probe resolves an absent key to (wiring tests).</summary>
public AbCipTransport DefaultTransport => _defaultTransport;
/// <inheritdoc />
public string DriverType => "AbCip";
@@ -123,7 +134,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
ConnectionSize: firstDevice?.ConnectionSize ?? profile.DefaultConnectionSize,
UseConnectedMessaging: profile.SupportsConnectedMessaging);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts)).Tags).Create(p);
var rt = (_tagFactory ?? AbCipTransportFactories.For(AbCipTransportResolver.Resolve(opts, _defaultTransport)).Tags).Create(p);
try
{
await rt.InitializeAsync(cts.Token);
@@ -5,32 +5,61 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// <see cref="AbCipDriverOptions.Transport"/>. The driver constructor, the Test Connect probe and the
/// CLI (through the driver) all resolve through here, so they cannot disagree about what an absent
/// <c>transport</c> key means.
/// <para>Precedence (final review I1): an explicit per-driver <c>transport</c> key wins; otherwise the
/// node-level default (<c>Drivers:AbCip:DefaultTransport</c>, read in the Host composition root and passed
/// in as <c>nodeDefault</c>); otherwise the compiled <see cref="Default"/>.</para>
/// </summary>
public static class AbCipTransportResolver
{
/// <summary>The transport an absent / null <c>transport</c> key selects.</summary>
/// <summary>The compiled default: the transport an absent key selects when no node default is configured.</summary>
public const AbCipTransport Default = AbCipTransport.Native;
/// <summary>Returns the effective transport: an explicit value always wins; null means <see cref="Default"/>.</summary>
/// <summary>Returns the effective transport: an explicit value always wins; null means <paramref name="nodeDefault"/>.</summary>
/// <param name="configured">The configured transport, or null for "use the default".</param>
/// <param name="nodeDefault">The node-level default for drivers with no key (the compiled <see cref="Default"/> unless pinned).</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipTransport? configured) => configured ?? Default;
public static AbCipTransport Resolve(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured ?? nodeDefault;
/// <summary>Returns the effective transport for <paramref name="options"/>.</summary>
/// <param name="options">The driver options.</param>
/// <param name="nodeDefault">The node-level default for drivers with no key.</param>
/// <returns>The transport the driver runs on.</returns>
public static AbCipTransport Resolve(AbCipDriverOptions options)
public static AbCipTransport Resolve(AbCipDriverOptions options, AbCipTransport nodeDefault = Default)
{
ArgumentNullException.ThrowIfNull(options);
return Resolve(options.Transport);
return Resolve(options.Transport, nodeDefault);
}
/// <summary>
/// Parses a transport name the way the driver-config <c>transport</c> key and the node-level default
/// are parsed: a defined enum NAME, case-insensitive. Numeric strings ("0", "7") and the flags form
/// ("Native,Libplctag") are rejected — <c>Enum.TryParse</c> alone accepts both.
/// </summary>
/// <param name="raw">The raw value.</param>
/// <param name="transport">The parsed transport.</param>
/// <returns>True when <paramref name="raw"/> names a transport.</returns>
public static bool TryParse(string? raw, out AbCipTransport transport)
{
transport = Default;
if (string.IsNullOrWhiteSpace(raw)) return false;
// Exact name match only: Enum.TryParse would also accept "1" and the flags form "Native,Libplctag".
var name = Enum.GetNames<AbCipTransport>()
.FirstOrDefault(n => string.Equals(n, raw.Trim(), StringComparison.OrdinalIgnoreCase));
return name is not null && Enum.TryParse(name, out transport);
}
/// <summary>
/// Operator-facing description of the configured-vs-effective transport for the init log line:
/// <c>Native (default)</c> when the key was absent, the bare transport name when it was explicit.
/// <c>Native (default)</c> when the key was absent and the compiled default applies,
/// <c>Libplctag (node default)</c> when the key was absent and the node pinned a different default,
/// <c>Native (configured)</c> when the key was explicit.
/// </summary>
/// <param name="configured">The configured transport, or null.</param>
/// <param name="nodeDefault">The node-level default.</param>
/// <returns>A short description.</returns>
public static string Describe(AbCipTransport? configured) =>
configured is null ? $"{Resolve(configured)} (default)" : $"{configured} (configured)";
public static string Describe(AbCipTransport? configured, AbCipTransport nodeDefault = Default) =>
configured is not null ? $"{configured} (configured)"
: nodeDefault == Default ? $"{nodeDefault} (default)"
: $"{nodeDefault} (node default)";
}
@@ -35,11 +35,11 @@
<div class="col-md-3">
<label class="form-label" for="abcipTransport">Transport</label>
<InputSelect @bind-Value="_form.Transport" @bind-Value:after="EmitAsync" class="form-select form-select-sm" id="abcipTransport">
<option value="">Default (Native)</option>
<option value="">Default (node setting; Native unless pinned)</option>
<option value="@AbCipTransport.Native">Native (managed)</option>
<option value="@AbCipTransport.Libplctag">libplctag (fallback)</option>
</InputSelect>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the driver picks; takes effect on the next deploy.</div>
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting so the node-level <code>Drivers:AbCip:DefaultTransport</code> picks (Native unless an operator pinned it); takes effect on the next deploy.</div>
</div>
</div>
</div>
@@ -0,0 +1,58 @@
using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
/// <summary>
/// Node-level defaults for the AbCip driver (final review I1), bound from <c>Drivers:AbCip</c>. Lets an
/// operator pin the transport an AbCip driver with NO <c>transport</c> key runs on — at upgrade time,
/// without editing (or being able to edit, #576) every driver config. An explicit per-driver
/// <c>transport</c> key still wins. Overridable by environment variable
/// <c>Drivers__AbCip__DefaultTransport</c>.
/// </summary>
public sealed class AbCipDriverDefaultsOptions
{
/// <summary>The configuration section path.</summary>
public const string SectionName = "Drivers:AbCip";
/// <summary>
/// <c>Native</c> (the default) or <c>Libplctag</c>, case-insensitive. Blank/absent = <c>Native</c>.
/// Any other value fails host start (<see cref="AbCipDriverDefaultsOptionsValidator"/>).
/// </summary>
public string? DefaultTransport { get; set; } = nameof(AbCipTransport.Native);
/// <summary>
/// The parsed default: blank resolves to the compiled <see cref="AbCipTransportResolver.Default"/>.
/// Throws on an unparseable value — unreachable at runtime because the validator fails host start first.
/// </summary>
/// <returns>The node-level default transport.</returns>
public AbCipTransport ResolveDefaultTransport()
{
if (string.IsNullOrWhiteSpace(DefaultTransport)) return AbCipTransportResolver.Default;
return AbCipTransportResolver.TryParse(DefaultTransport, out var t)
? t
: throw new InvalidOperationException(AbCipDriverDefaultsOptionsValidator.Message(DefaultTransport));
}
}
/// <summary>Fail-fast startup validator for <see cref="AbCipDriverDefaultsOptions"/>.</summary>
public sealed class AbCipDriverDefaultsOptionsValidator : OptionsValidatorBase<AbCipDriverDefaultsOptions>
{
/// <summary>The operator-facing failure text for a bad value.</summary>
/// <param name="value">The rejected value.</param>
/// <returns>The message.</returns>
internal static string Message(string? value) =>
$"Drivers:AbCip:DefaultTransport is '{value}' — expected one of " +
$"{string.Join(", ", Enum.GetNames<AbCipTransport>())} (case-insensitive). It selects the transport an " +
"AbCip driver with no \"transport\" key runs on; set it via appsettings or the environment variable " +
"Drivers__AbCip__DefaultTransport.";
/// <inheritdoc />
protected override void Validate(ValidationBuilder builder, AbCipDriverDefaultsOptions options)
{
builder.RequireThat(
string.IsNullOrWhiteSpace(options.DefaultTransport)
|| AbCipTransportResolver.TryParse(options.DefaultTransport, out _),
Message(options.DefaultTransport));
}
}
@@ -1,10 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
using ZB.MOM.WW.OtOpcUa.Runtime.Redundancy;
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
@@ -78,7 +80,8 @@ public static class DriverFactoryBootstrap
// whole mechanism exists to prevent, and it would fail as a plant outage rather than as a
// startup error.
var primaryRoleSource = sp.GetRequiredService<IDriverPrimaryRoleSource>();
Register(registry, loggerFactory, secretResolver, primaryRoleSource, scriptRoot);
Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp),
scriptRoot);
return registry;
});
services.AddSingleton<IDriverFactory>(sp =>
@@ -140,7 +143,12 @@ public static class DriverFactoryBootstrap
public static IServiceCollection AddOtOpcUaDriverProbes(this IServiceCollection services)
{
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, ModbusProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>());
// Factory-built so the probe resolves an absent "transport" key to the SAME node default the driver
// factory uses (Drivers:AbCip:DefaultTransport, final review I1) — Test Connect probes the stack the
// driver would run. The generic factory overload keeps AbCipProbe as the implementation type, which
// TryAddEnumerable de-dups on.
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbCipProbe>(
sp => new AbCipProbe(tagFactory: null, defaultTransport: ResolveAbCipDefaultTransport(sp))));
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, AbLegacyProbe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, S7Probe>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<IDriverProbe, TwinCATProbe>());
@@ -155,6 +163,16 @@ public static class DriverFactoryBootstrap
return services;
}
/// <summary>
/// The node-level AbCip default transport (<c>Drivers:AbCip:DefaultTransport</c>, bound + validated in
/// Program.cs). A container without the options bound (test graphs) gets the compiled default.
/// </summary>
/// <param name="sp">The service provider.</param>
/// <returns>The transport an AbCip driver with no <c>transport</c> key runs on.</returns>
internal static Driver.AbCip.AbCipTransport ResolveAbCipDefaultTransport(IServiceProvider sp) =>
sp.GetService<IOptions<AbCipDriverDefaultsOptions>>()?.Value.ResolveDefaultTransport()
?? Driver.AbCip.AbCipTransportResolver.Default;
/// <summary>
/// Invoke every cross-platform driver's <c>Register</c> extension. New driver assemblies
/// get added here — one line per type. ShouldStub() in <c>DriverInstanceActor</c> still
@@ -166,9 +184,10 @@ public static class DriverFactoryBootstrap
ILoggerFactory? loggerFactory,
ISecretResolver secretResolver,
IDriverPrimaryRoleSource primaryRoleSource,
Driver.AbCip.AbCipTransport abCipDefaultTransport,
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
{
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport);
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
@@ -175,6 +175,13 @@ builder.Services.AddOtOpcUaCluster(builder.Configuration);
// the startup check (AddOtOpcUaAuth binds LdapOptions but does not attach a validator).
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
// Node-level AbCip default transport (final review I1): the transport an AbCip driver with NO "transport"
// key runs on. Unconditional because BOTH consumers need it — the driver factory (driver role) and the
// Test Connect probe (admin role, which must probe the stack the driver would run). A bad value fails host
// start here rather than silently falling back.
builder.Services.AddValidatedOptions<AbCipDriverDefaultsOptions, AbCipDriverDefaultsOptionsValidator>(
builder.Configuration, AbCipDriverDefaultsOptions.SectionName);
if (hasDriver)
{
builder.Services.AddOtOpcUaRuntime();