PR 4.3 — IWritable + secured-write routing
Write path online. GalaxyDriver implements IWritable; routes by SecurityClassification — SecuredWrite / VerifiedWrite tags go through MxCommandKind.WriteSecured, everything else through MxGatewaySession. WriteAsync. Per-tag classifications are captured during ITagDiscovery via a SecurityCapturingBuilder wrapper that intercepts Variable() calls without the discoverer needing to know about the driver's internal state. Files: - Runtime/MxValueEncoder.cs — boxed CLR value → MxValue. Covers seven Galaxy scalar types (bool/int8-32/uint8-32 → Int32, int64/uint64 → Int64, float, double, string, DateTime/DateTimeOffset → Timestamp) and 1-D array variants. Inverse of MxValueDecoder; round-trip pinned by tests. DateTime.Local converts to UTC; unsupported types throw ArgumentException. - Runtime/IGalaxyDataWriter.cs — driver-side seam. Tests inject a fake to capture routing decisions; production path uses GatewayGalaxyDataWriter. - Runtime/GatewayGalaxyDataWriter.cs — production. Lazy-AddItem caches itemHandles, encodes value, routes Write vs WriteSecured, translates MxCommandReply (ProtocolStatus → BadCommunicationError; first MxStatusProxy in statuses[] via StatusCodeMap.FromMxStatus). Per-tag exception isolation: one bad write doesn't fail the batch. - GalaxyDriver: now implements IWritable. Discovery wraps the supplied IAddressSpaceBuilder in SecurityCapturingBuilder which records each attribute's SecurityClass into _securityByFullRef before delegating. WriteAsync resolves classification per tag (FreeAccess default for unknown tags — matches the legacy backend), routes through the injected writer. Throws NotSupportedException with PR 4.4 pointer when no writer is wired (production path requires GalaxyMxSession.Connect from PR 4.4). Tests (32 new, 94 Galaxy total): - MxValueEncoder: every scalar type, narrowing checks (sbyte/short/byte/ ushort fit Int32; uint within Int32 range; ulong within Int64), DateTime.Local → UTC conversion, array variants for bool/double/string/ DateTime, Dimensions populated, unsupported-type throws ArgumentException, encoder/decoder round-trip pin. - GalaxyDriverWriteTests: WriteAsync routes through fake writer with values intact; theory exercises every SecurityClassification value through the discovery-then-write path; unknown-tag defaults to FreeAccess; empty- request short-circuit; no-writer fail-loud; post-dispose throws. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
/// <see cref="GalaxyDriverFactoryExtensions"/> registers under driver-type name
|
||||
/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing.
|
||||
/// </remarks>
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposable
|
||||
public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IWritable, IDisposable
|
||||
{
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly GalaxyDriverOptions _options;
|
||||
@@ -43,6 +43,14 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
// capability-routing).
|
||||
private readonly IGalaxyDataReader? _dataReader;
|
||||
|
||||
// PR 4.3 — IGalaxyDataWriter is the test seam for IWritable. Production wraps
|
||||
// GalaxyMxSession via GatewayGalaxyDataWriter (Write / WriteSecured routing). The
|
||||
// per-tag SecurityClassification map is populated during ITagDiscovery and consumed
|
||||
// here at write time.
|
||||
private readonly IGalaxyDataWriter? _dataWriter;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification>
|
||||
_securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
@@ -50,20 +58,21 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
: this(driverInstanceId, options, hierarchySource: null, dataReader: null, logger)
|
||||
: this(driverInstanceId, options, hierarchySource: null, dataReader: null, dataWriter: null, logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/> +
|
||||
/// <see cref="ReadAsync"/> can be exercised against canned data without building
|
||||
/// real gRPC channels.
|
||||
/// Test-visible ctor — inject custom seams so <see cref="DiscoverAsync"/>,
|
||||
/// <see cref="ReadAsync"/>, and <see cref="WriteAsync"/> can be exercised against
|
||||
/// canned data without building real gRPC channels.
|
||||
/// </summary>
|
||||
internal GalaxyDriver(
|
||||
string driverInstanceId,
|
||||
GalaxyDriverOptions options,
|
||||
IGalaxyHierarchySource? hierarchySource,
|
||||
IGalaxyDataReader? dataReader = null,
|
||||
IGalaxyDataWriter? dataWriter = null,
|
||||
ILogger<GalaxyDriver>? logger = null)
|
||||
{
|
||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||
@@ -73,6 +82,7 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||
_hierarchySource = hierarchySource;
|
||||
_dataReader = dataReader;
|
||||
_dataWriter = dataWriter;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -135,11 +145,18 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
// PR 4.3 — wrap the supplied builder in a capturing proxy that records each
|
||||
// attribute's SecurityClassification into _securityByFullRef. The wrapper is
|
||||
// transparent to GalaxyDiscoverer; it forwards every call to the real builder.
|
||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
||||
var discoverer = new GalaxyDiscoverer(source);
|
||||
await discoverer.DiscoverAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||
await discoverer.DiscoverAsync(capturingBuilder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private SecurityClassification ResolveSecurity(string fullReference) =>
|
||||
_securityByFullRef.TryGetValue(fullReference, out var sec) ? sec : SecurityClassification.FreeAccess;
|
||||
|
||||
// ===== IReadable (PR 4.2 — abstraction; PR 4.4 supplies production reader) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -165,6 +182,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
return _dataReader.ReadAsync(fullReferences, cancellationToken);
|
||||
}
|
||||
|
||||
// ===== IWritable (PR 4.3) =====
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
if (writes.Count == 0) return Task.FromResult<IReadOnlyList<WriteResult>>([]);
|
||||
|
||||
if (_dataWriter is null)
|
||||
{
|
||||
// Mirror the IReadable fallback: production write path runs on top of
|
||||
// GalaxyMxSession (PR 4.2 skeleton; PR 4.4 wires the live session). Until
|
||||
// that lands, deployments selecting Galaxy:Backend=mxgateway can't write.
|
||||
throw new NotSupportedException(
|
||||
"GalaxyDriver.WriteAsync requires GatewayGalaxyDataWriter wired against a connected " +
|
||||
"GalaxyMxSession (PR 4.4). Until that lands, route writes through the legacy-host " +
|
||||
"backend (Galaxy:Backend=legacy-host).");
|
||||
}
|
||||
|
||||
return _dataWriter.WriteAsync(writes, ResolveSecurity, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily builds the default <see cref="IGalaxyHierarchySource"/> from
|
||||
/// <c>_options.Gateway</c>. Owned <see cref="GalaxyRepositoryClient"/> is disposed in
|
||||
@@ -200,4 +241,30 @@ public sealed class GalaxyDriver : IDriver, ITagDiscovery, IReadable, IDisposabl
|
||||
_ownedRepositoryClient = null;
|
||||
_hierarchySource = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Address-space builder wrapper that records each variable's
|
||||
/// <see cref="DriverAttributeInfo.SecurityClass"/> into the supplied dictionary
|
||||
/// before delegating to the inner builder. Used by <see cref="DiscoverAsync"/>
|
||||
/// to capture per-tag classifications for the IWritable routing decision —
|
||||
/// PR 4.3 needs the data, but the discoverer itself doesn't (and shouldn't)
|
||||
/// know about the driver's internal state.
|
||||
/// </summary>
|
||||
private sealed class SecurityCapturingBuilder(
|
||||
IAddressSpaceBuilder inner,
|
||||
System.Collections.Concurrent.ConcurrentDictionary<string, SecurityClassification> map)
|
||||
: IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
=> new SecurityCapturingBuilder(inner.Folder(browseName, displayName), map);
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
{
|
||||
map[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
return inner.Variable(browseName, displayName, attributeInfo);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value)
|
||||
=> inner.AddProperty(browseName, dataType, value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user