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:
@@ -25,7 +25,9 @@ public enum AbCipDataType
|
||||
Real, // 32-bit IEEE-754
|
||||
LReal, // 64-bit IEEE-754
|
||||
String, // Logix STRING (DINT Length + SINT[82] DATA — flattened to .NET string by libplctag)
|
||||
Dt, // Date/Time — Logix DT == DINT representing seconds-since-epoch per Rockwell conventions
|
||||
Dt, // Logix DATE (0xCD — 4-byte unsigned days since 1984-01-01) or DATE_AND_TIME / DT
|
||||
// (0xCF — 8-byte unsigned microseconds since 1970-01-01). The driver reads 4 bytes
|
||||
// via GetInt32; DATE decodes correctly, DATE_AND_TIME is truncated to the low 4 bytes.
|
||||
/// <summary>
|
||||
/// UDT / Predefined Structure (Timer / Counter / Control / Message / Axis). Shape is
|
||||
/// resolved at discovery time; reads + writes fan out to member Variables unless the
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
@@ -81,8 +79,7 @@ public sealed class AbCipDriverOptions
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
/// </summary>
|
||||
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
|
||||
[Range(1, 60)]
|
||||
/// <remarks>Valid range: 1–60 seconds; the AdminUI clamps to 60s server-side.</remarks>
|
||||
public int ProbeTimeoutSeconds { get; init; } = 5;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ public static class AbCipEquipmentTagParser
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbCip TagConfig object.</returns>
|
||||
/// <remarks>
|
||||
/// The produced <see cref="AbCipTagDefinition.Writable"/> is always <c>true</c>: the
|
||||
/// TagConfig JSON format for equipment tags does not carry a writability field, so the
|
||||
/// PLC's ExternalAccess attribute is the effective write gate. Operators who need a
|
||||
/// read-only OPC UA surface must rely on the PLC's ExternalAccess rejecting the write.
|
||||
/// <see cref="AbCipTagDefinition.Writable"/> is read from the optional <c>"writable"</c>
|
||||
/// boolean field in the TagConfig JSON; it defaults to <c>true</c> when the field is absent,
|
||||
/// matching the record's documented default and the behaviour of pre-declared tags. Operators
|
||||
/// who need a read-only OPC UA surface can author <c>"writable":false</c> in the TagConfig;
|
||||
/// the PLC's ExternalAccess attribute remains the effective write gate at the wire level.
|
||||
/// </remarks>
|
||||
public static bool TryParse(string reference, out AbCipTagDefinition def)
|
||||
{
|
||||
@@ -36,6 +37,11 @@ public static class AbCipEquipmentTagParser
|
||||
if (string.IsNullOrWhiteSpace(tagPath)) return false;
|
||||
|
||||
var deviceHostAddress = ReadString(root, "deviceHostAddress");
|
||||
// A "dataType":"Structure" input is accepted and produces a Structure-typed definition
|
||||
// with Members:null. The driver treats this as a black-box dotted-path read: libplctag
|
||||
// resolves the full tag path (e.g. "Motor.Speed") without enumerating UDT members.
|
||||
// The address space emits a placeholder String variable; UDT member declarations are
|
||||
// not supported in the equipment-tag flow.
|
||||
var dataType = ReadEnum(root, "dataType", AbCipDataType.DInt);
|
||||
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A
|
||||
// 1-element array (isArray:true, arrayLength:1) is a VALID 1-element array — the
|
||||
@@ -43,9 +49,12 @@ public static class AbCipEquipmentTagParser
|
||||
// scalar. ElementCount can't carry the signal (a scalar and a 1-element array both
|
||||
// have a count of 1), so the explicit IsArray flag does.
|
||||
var (isArray, elementCount) = ReadArrayShape(root);
|
||||
// "writable" defaults to true when absent — matches AbCipTagDefinition.Writable default.
|
||||
var writable = !root.TryGetProperty("writable", out var writableEl)
|
||||
|| writableEl.ValueKind != JsonValueKind.False;
|
||||
def = new AbCipTagDefinition(
|
||||
Name: reference, DeviceHostAddress: deviceHostAddress, TagPath: tagPath,
|
||||
DataType: dataType, Writable: true, ElementCount: elementCount, IsArray: isArray);
|
||||
DataType: dataType, Writable: writable, ElementCount: elementCount, IsArray: isArray);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException) { return false; }
|
||||
|
||||
@@ -181,6 +181,11 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;
|
||||
|
||||
var nowFaulted = ToBool(inFaultedDv.Value);
|
||||
// severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
|
||||
// ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
|
||||
// InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
|
||||
// InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
|
||||
// is intentional and matches the behaviour documented on Driver.AbCip-017.
|
||||
var severity = ToInt(severityDv.Value);
|
||||
|
||||
var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
|
||||
|
||||
@@ -12,6 +12,17 @@ public static class AbLegacyEquipmentTagParser
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON (also used as the def identity).</param>
|
||||
/// <param name="def">The transient definition when parsing succeeds.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="reference"/> is an AbLegacy address object.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <c>isArray</c> is the JSON literal <see langword="true"/> but <c>arrayLength</c> is
|
||||
/// absent, zero, or negative, the result is silently a <b>scalar</b>
|
||||
/// (<see cref="AbLegacyTagDefinition.ArrayLength"/> is <see langword="null"/>).
|
||||
/// A valid positive <c>arrayLength</c> is required to produce an array tag; <c>isArray:true</c>
|
||||
/// alone is not sufficient. This is intentional: a stale length behind a cleared or absent
|
||||
/// <c>isArray</c> flag must never produce an orphan array tag that mismatches its scalar OPC UA
|
||||
/// node (see in-source comment, review C-2).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool TryParse(string reference, out AbLegacyTagDefinition def)
|
||||
{
|
||||
def = null!;
|
||||
|
||||
@@ -7,12 +7,25 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
public sealed record AbLegacyPlcFamilyProfile(
|
||||
string LibplctagPlcAttribute,
|
||||
string DefaultCipPath,
|
||||
/// <summary>
|
||||
/// Reserved for future array-length clamping. <b>Not currently enforced anywhere in the
|
||||
/// driver.</b> The values are approximate upper bounds derived from PCCC packet payload
|
||||
/// limits (e.g. SLC 5/05 240 bytes is the PCCC-over-EIP data cap, not a libplctag fragment
|
||||
/// limit). Do not rely on this field for sizing decisions until an enforcement point is added.
|
||||
/// </summary>
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
{
|
||||
/// <summary>Gets the profile for the specified PLC family.</summary>
|
||||
/// <param name="family">The PLC family.</param>
|
||||
/// <remarks>
|
||||
/// Any unrecognised <paramref name="family"/> value (e.g. an integer cast to the enum, or a
|
||||
/// value added to <see cref="AbLegacyPlcFamily"/> before this switch is updated) silently
|
||||
/// returns the <see cref="Slc500"/> profile. This is intentional: it preserves
|
||||
/// forward-compatibility for device configs authored against a build that predates a new
|
||||
/// family enum member, preferring a safe default over a startup exception.
|
||||
/// </remarks>
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
AbLegacyPlcFamily.Slc500 => Slc500,
|
||||
|
||||
@@ -157,9 +157,9 @@ public sealed record FocasDeviceOptions(
|
||||
/// <c>FocasReadWriteTests</c>). Defaults to <c>true</c>.
|
||||
/// </param>
|
||||
/// <param name="WriteIdempotent">
|
||||
/// Whether repeated writes of the same value are safe. Carried for parity; not yet
|
||||
/// threaded through to <c>DriverAttributeInfo</c> in <c>DiscoverAsync</c> (see
|
||||
/// Driver.FOCAS.Contracts-002). Defaults to <c>false</c>.
|
||||
/// Whether repeated writes of the same value are safe. Threaded through to
|
||||
/// <c>DriverAttributeInfo.WriteIdempotent</c> by <c>DiscoverAsync</c> so OPC UA
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -487,7 +487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
SecurityClass: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: false));
|
||||
WriteIdempotent: tag.WriteIdempotent));
|
||||
}
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
|
||||
@@ -122,14 +122,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
/// <summary>
|
||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
||||
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved
|
||||
/// inline (a slim version of <c>GalaxyDriver.ResolveApiKey</c>) because the
|
||||
/// Browser project doesn't reference Driver.Galaxy.
|
||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||
/// </summary>
|
||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
||||
{
|
||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||
ApiKey = ResolveApiKey(gw.ApiKeySecretRef),
|
||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
||||
UseTls = gw.UseTls,
|
||||
CaCertificatePath = gw.CaCertificatePath,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||
@@ -138,57 +138,4 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||
: null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>env:NAME</c>, <c>file:PATH</c>, and <c>dev:KEY</c> prefixes;
|
||||
/// anything else is treated as a literal cleartext key with a startup warning.
|
||||
/// Slim mirror of <c>GalaxyDriver.ResolveApiKey</c> — the runtime version lives
|
||||
/// in a sibling project the Browser intentionally doesn't reference.
|
||||
/// </summary>
|
||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||
private string ResolveApiKey(string secretRef)
|
||||
{
|
||||
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 when they open the address picker.
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed record GalaxyDriverOptions(
|
||||
|
||||
/// <summary>
|
||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||
/// resolved by <c>GalaxyDriver.ResolveApiKey</c> at InitializeAsync time. Four forms
|
||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
||||
/// supported, in priority order:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. 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 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>
|
||||
/// <remarks>
|
||||
/// Lives in the Contracts project so both the runtime <c>GalaxyDriver</c> and the
|
||||
/// AdminUI <c>GalaxyDriverBrowser</c> (which intentionally don't reference each
|
||||
/// other) share a single resolver rather than each maintaining a copy.
|
||||
/// </remarks>
|
||||
public static class GalaxySecretRef
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
||||
/// a <see cref="LogLevel.Warning"/>. 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>
|
||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
||||
{
|
||||
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. 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;
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -5,5 +5,9 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<!-- NO PackageReference. NO ProjectReference. -->
|
||||
<!-- NO ProjectReference. The only PackageReference is the logging abstraction -->
|
||||
<!-- needed by GalaxySecretRef.ResolveApiKey's optional warning logger. -->
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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