fix(code-review): resolve Batch 2 open findings (AbCip, AbLegacy, Galaxy, FOCAS)
- Driver.AbCip.Contracts-001: parse 'writable' from TagConfig JSON (default true) instead of hardcoding - Driver.AbCip.Contracts-002/-003: Dt type comment; drop dead [Display]/[Range] annotations - Driver.AbCip.Contracts-004: dedicated AbCipEquipmentTagParser test class (+15) - Driver.AbCip-017: document Tick severity Low-fallback on Bad severity read - Driver.AbLegacy.Contracts-002/-003/-004: isArray-scalar remarks (+tests), MaxTagBytes/ForFamily docs - Driver.Galaxy.Browser-003 + Driver.Galaxy.Contracts-003: extract ResolveApiKey -> GalaxySecretRef (dedup) - Driver.Galaxy-019: cache buffered-interval only on Ok + ILogger warnings + ClassifyIntervalReply (+tests) - Driver.FOCAS.Contracts-002: thread WriteIdempotent through DiscoverAsync (+test)
This commit is contained in:
@@ -252,7 +252,7 @@ public sealed class GalaxyDriver
|
||||
// listener (OTLP exporter, dotnet-trace, etc.) consumes these without the driver
|
||||
// taking a dependency on the OpenTelemetry packages.
|
||||
_subscriber = new TracedGalaxySubscriber(
|
||||
new GatewayGalaxySubscriber(_ownedMxSession), _options.MxAccess.ClientName);
|
||||
new GatewayGalaxySubscriber(_ownedMxSession, _logger), _options.MxAccess.ClientName);
|
||||
_dataWriter = new TracedGalaxyDataWriter(
|
||||
// Let the writer borrow live MXAccess item handles the subscription registry already
|
||||
// holds, so the first write to an already-subscribed tag skips a redundant AddItem.
|
||||
@@ -437,91 +437,14 @@ public sealed class GalaxyDriver
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key bytes. Four
|
||||
/// forms supported, evaluated in order:
|
||||
/// <list type="number">
|
||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||
/// Throws when the variable is unset, so a misconfigured deployment fails
|
||||
/// fast at InitializeAsync rather than silently sending an empty key.</item>
|
||||
/// <item><c>file:PATH</c> — reads UTF-8 text from <c>PATH</c>, trimming
|
||||
/// whitespace. Lets operators stash the key in an ACL'd file outside the
|
||||
/// repo (the same pattern as the legacy <c>.local/galaxy-host-secret.txt</c>).</item>
|
||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
||||
/// is a deliberate opt-in signal (dev box, parity rig) so the resolver
|
||||
/// doesn't emit a warning; production should never use this arm.</item>
|
||||
/// <item>Anything else — used as the literal API key for back-compat with
|
||||
/// configs that pre-date this resolver. When a logger is supplied the
|
||||
/// resolver emits a startup warning so an operator who accidentally
|
||||
/// committed a cleartext key sees it (Driver.Galaxy-010).</item>
|
||||
/// </list>
|
||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
||||
/// changing the call site.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
internal static string ResolveApiKey(string secretRef) => ResolveApiKey(secretRef, logger: null);
|
||||
|
||||
/// <summary>
|
||||
/// Logger-aware overload. Emits a <see cref="LogLevel.Warning"/> if the secret
|
||||
/// ref falls through to the back-compat literal arm (an unprefixed cleartext
|
||||
/// API key in <c>DriverConfig</c> JSON). The <c>dev:</c> prefix is the explicit
|
||||
/// opt-in path that doesn't warn.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||
internal static string ResolveApiKey(string secretRef, ILogger? logger)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||
|
||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var name = secretRef[4..];
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves to env var '{name}', but it is unset.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = secretRef[5..];
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' points at '{path}', which doesn't exist.");
|
||||
}
|
||||
var contents = File.ReadAllText(path).Trim();
|
||||
return !string.IsNullOrEmpty(contents)
|
||||
? contents
|
||||
: throw new InvalidOperationException(
|
||||
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' file '{path}' is empty.");
|
||||
}
|
||||
|
||||
if (secretRef.StartsWith("dev:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Explicit dev opt-in — no warning, the operator deliberately chose a
|
||||
// cleartext literal (dev box, parity rig).
|
||||
return secretRef[4..];
|
||||
}
|
||||
|
||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
||||
// API key — but emit a warning so an operator who accidentally committed a
|
||||
// cleartext key into DriverConfig sees it at startup. Use the dev: prefix
|
||||
// to suppress this warning when the literal is intentional.
|
||||
logger?.LogWarning(
|
||||
"Galaxy.Gateway.ApiKeySecretRef is being treated as a literal cleartext API key. " +
|
||||
"Prefer env:NAME, file:PATH, or the explicit dev:KEY prefix for dev rigs — " +
|
||||
"a literal key in DriverConfig JSON is stored in cleartext in the central config DB.");
|
||||
return secretRef;
|
||||
}
|
||||
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
// Driver.Galaxy-010: pass the logger so the literal-arm cleartext fallback
|
||||
// surfaces a startup warning rather than silently shipping the key.
|
||||
ApiKey = ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
// surfaces a startup warning rather than silently shipping the key. The
|
||||
// resolver lives in Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime
|
||||
// driver and the AdminUI browser share one implementation.
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
// Use the generated nested status enum for the SetBufferedUpdateInterval reply check.
|
||||
@@ -18,14 +20,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
{
|
||||
private readonly GalaxyMxSession _session;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Lock _intervalLock = new();
|
||||
private int _lastAppliedIntervalMs = -1; // -1 = never applied; 0 = explicit "use gw default"
|
||||
|
||||
/// <summary>Initializes a new instance of GatewayGalaxySubscriber.</summary>
|
||||
/// <param name="session">The Galaxy MX session to use for subscription operations.</param>
|
||||
public GatewayGalaxySubscriber(GalaxyMxSession session)
|
||||
/// <param name="logger">
|
||||
/// Optional logger; surfaces a warning when <c>SetBufferedUpdateInterval</c>
|
||||
/// soft-fails so the cadence-not-applied condition isn't silent. Null is allowed
|
||||
/// for unit-test construction and falls back to <see cref="NullLogger.Instance"/>.
|
||||
/// </param>
|
||||
public GatewayGalaxySubscriber(GalaxyMxSession session, ILogger? logger = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_logger = logger ?? NullLogger.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Subscribes to a bulk list of Galaxy references with optional buffered update interval.</summary>
|
||||
@@ -86,11 +95,33 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (reply.ProtocolStatus?.Code is not (ProtocolStatusCode.Ok or ProtocolStatusCode.MxaccessFailure))
|
||||
var code = reply.ProtocolStatus?.Code;
|
||||
|
||||
// MxaccessFailure means the COM-side SetBufferedUpdateInterval did NOT apply, so
|
||||
// we must NOT cache the requested value — caching it would suppress the retry on
|
||||
// the next subscribe at this interval. Only Ok records the value as applied.
|
||||
if (!ClassifyIntervalReply(code))
|
||||
{
|
||||
// Don't throw on a soft failure — the SubscribeBulk will still succeed at the
|
||||
// gw's default cadence, which is functional just not the requested cadence.
|
||||
// The trace span (PR 6.1) plus the warning here gives ops the signal.
|
||||
// The trace span (PR 6.1) plus this warning gives ops the signal, and leaving
|
||||
// _lastAppliedIntervalMs unchanged lets the next subscribe re-attempt the set.
|
||||
if (code == ProtocolStatusCode.MxaccessFailure)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) soft-failed (MxaccessFailure); " +
|
||||
"buffered subscriptions on server handle {ServerHandle} will publish at the gateway's " +
|
||||
"default cadence. The requested interval was not cached, so a later subscribe will retry it.",
|
||||
intervalMs, serverHandle);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Galaxy SetBufferedUpdateInterval({IntervalMs}ms) returned an unexpected protocol status " +
|
||||
"{Code} on server handle {ServerHandle}; treating it as not-applied and leaving the " +
|
||||
"requested interval uncached so a later subscribe retries it.",
|
||||
intervalMs, code, serverHandle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,6 +131,17 @@ public sealed class GatewayGalaxySubscriber : IGalaxySubscriber
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classifies a <c>SetBufferedUpdateInterval</c> reply: returns <c>true</c> only when
|
||||
/// the requested interval was actually applied and may be cached as last-applied.
|
||||
/// This is <see cref="ProtocolStatusCode.Ok"/> alone — <see cref="ProtocolStatusCode.MxaccessFailure"/>
|
||||
/// means the COM-side set did NOT take effect (so caching it would prevent a retry),
|
||||
/// and a <c>null</c> or any other unexpected code is treated as not-applied.
|
||||
/// </summary>
|
||||
/// <param name="code">The protocol status code from the gateway reply, or <c>null</c> when absent.</param>
|
||||
/// <returns><c>true</c> if the interval should be recorded as applied; otherwise <c>false</c>.</returns>
|
||||
internal static bool ClassifyIntervalReply(ProtocolStatusCode? code) => code == ProtocolStatusCode.Ok;
|
||||
|
||||
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
|
||||
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
|
||||
Reference in New Issue
Block a user