feat(secrets): G-2a secret: arm on GalaxySecretRef via ISecretResolver (Task 7)
Add a secret:NAME arm to GalaxySecretRef.ResolveApiKey that resolves the Galaxy gateway API key through the shared ISecretResolver — fail-closed if the secret is absent (never falls through to the cleartext literal arm), retiring the dev:/literal in-DB path for production. Because GetAsync is async the method becomes ResolveApiKeyAsync; the await cascade threads ISecretResolver by ctor injection into GalaxyDriver + GalaxyDriverBrowser and (since GalaxyDriver is built by a static factory closure, not DI) through GalaxyDriverFactoryExtensions + DriverFactoryBootstrap (which pulls the real resolver from the service provider — registered unconditionally in Slice 1). A NullSecretResolver null-object backs the parse-only/test paths only; the runtime path always gets the real resolver (verified end-to-end). TDD: 3 new secret:-arm tests (resolve / fail-closed-on-absent / no-literal-warning) RED without the arm, GREEN with it; 338 Galaxy tests pass; no sync-over-async.
This commit is contained in:
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using ZB.MOM.WW.MxGateway.Client;
|
using ZB.MOM.WW.MxGateway.Client;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
|
||||||
|
|
||||||
@@ -34,11 +35,14 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
private readonly ILogger<GalaxyDriverBrowser> _logger;
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
/// <summary>Creates a new browser. Logger defaults to <see cref="NullLogger{T}"/>.</summary>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c> (DI-injected).</param>
|
||||||
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
/// <param name="logger">Optional logger; null is allowed for unit-test construction.</param>
|
||||||
public GalaxyDriverBrowser(ILogger<GalaxyDriverBrowser>? logger = null)
|
public GalaxyDriverBrowser(ISecretResolver secretResolver, ILogger<GalaxyDriverBrowser>? logger = null)
|
||||||
{
|
{
|
||||||
|
_secretResolver = secretResolver ?? throw new ArgumentNullException(nameof(secretResolver));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,7 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
if (string.IsNullOrWhiteSpace(opts.MxAccess.ClientName))
|
||||||
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
throw new InvalidOperationException("Galaxy browser requires MxAccess.ClientName.");
|
||||||
|
|
||||||
var clientOpts = BuildClientOptions(opts.Gateway);
|
var clientOpts = await BuildClientOptionsAsync(opts.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
// 30s wall-clock budget for the connect phase, linked to the caller's token so
|
||||||
// an AdminUI cancel still wins early.
|
// an AdminUI cancel still wins early.
|
||||||
@@ -116,19 +120,28 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
|||||||
/// Build the gateway client options from the form's Gateway section. Mirrors the
|
/// 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
|
/// runtime driver's <c>GalaxyDriver.BuildClientOptions</c> field-for-field so the
|
||||||
/// gateway sees an identical option shape. The API-key reference is resolved via
|
/// gateway sees an identical option shape. The API-key reference is resolved via
|
||||||
/// the shared <see cref="GalaxySecretRef.ResolveApiKey"/> in Driver.Galaxy.Contracts
|
/// the shared <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> in Driver.Galaxy.Contracts
|
||||||
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
/// (the same resolver the runtime driver uses), so browse and runtime stay in lock-step.
|
||||||
|
/// The <c>secret:</c> arm is async, so the key is resolved into a local before the
|
||||||
|
/// object initializer (you can't await inside one).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
var apiKey = await GalaxySecretRef
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
UseTls = gw.UseTls,
|
.ConfigureAwait(false);
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
return new MxGatewayClientOptions
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
{
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
ApiKey = apiKey,
|
||||||
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
UseTls = gw.UseTls,
|
||||||
: null,
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
};
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
|
StreamTimeout = gw.StreamTimeoutSeconds > 0
|
||||||
|
? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ public sealed record GalaxyDriverOptions(
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
/// Connection details for the MxAccess gateway. <see cref="ApiKeySecretRef"/> is
|
||||||
/// resolved by <see cref="GalaxySecretRef.ResolveApiKey"/> at InitializeAsync time. Four forms
|
/// resolved by <see cref="GalaxySecretRef.ResolveApiKeyAsync"/> at InitializeAsync time. Five forms
|
||||||
/// supported, in priority order:
|
/// supported, in priority order:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
/// <item><c>env:NAME</c> — read from an environment variable (recommended for
|
||||||
/// production; the central config DB holds only the indirection, not the key).</item>
|
/// production; the central config DB holds only the indirection, not the key).</item>
|
||||||
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
/// <item><c>file:PATH</c> — read from an ACL'd file outside the repo.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolved through the shared <c>ZB.MOM.WW.Secrets</c>
|
||||||
|
/// encrypted store; fail-closed if absent (the production path).</item>
|
||||||
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
/// <item><c>dev:KEY</c> — explicit cleartext opt-in for dev rigs / parity tests;
|
||||||
/// no startup warning.</item>
|
/// no startup warning.</item>
|
||||||
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
/// <item>Anything else — treated as a literal cleartext API key for back-compat.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Four
|
/// Resolves <c>Gateway.ApiKeySecretRef</c> to the actual API-key string. Five
|
||||||
/// forms supported, evaluated in order:
|
/// forms supported, evaluated in order:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
/// <item><c>env:NAME</c> — reads <c>Environment.GetEnvironmentVariable(NAME)</c>.
|
||||||
@@ -15,12 +16,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
/// <item><c>dev:KEY</c> — explicit cleartext literal. The <c>dev:</c> prefix
|
/// <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
|
/// 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>
|
/// doesn't emit a warning; production should never use this arm.</item>
|
||||||
|
/// <item><c>secret:NAME</c> — resolves NAME through the shared
|
||||||
|
/// <c>ZB.MOM.WW.Secrets</c> <see cref="ISecretResolver"/> (the encrypted-at-rest
|
||||||
|
/// store). Fail-closed: a <c>secret:</c> ref whose secret is absent/tombstoned
|
||||||
|
/// throws rather than falling through to the literal arm — the production path
|
||||||
|
/// that retires the cleartext <c>dev:</c>/literal-in-DB model.</item>
|
||||||
/// <item>Anything else — used as the literal API key for back-compat with
|
/// <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
|
/// configs that pre-date this resolver. When a logger is supplied the
|
||||||
/// resolver emits a startup warning so an operator who accidentally
|
/// resolver emits a startup warning so an operator who accidentally
|
||||||
/// committed a cleartext key sees it.</item>
|
/// committed a cleartext key sees it.</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// A future PR can swap any of these arms for a DPAPI-backed lookup without
|
/// A future PR can swap any of these arms for a different backing store without
|
||||||
/// changing the call site.
|
/// changing the call site.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -31,18 +37,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|||||||
public static class GalaxySecretRef
|
public static class GalaxySecretRef
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the supplied secret reference. When the ref falls through to the
|
/// Resolves the supplied secret reference. The <c>secret:NAME</c> arm resolves
|
||||||
/// back-compat literal arm (an unprefixed cleartext API key in
|
/// through <paramref name="resolver"/> and is fail-closed (throws when the secret
|
||||||
/// <c>DriverConfig</c> JSON) and a <paramref name="logger"/> is supplied, emits
|
/// is absent). When the ref falls through to the back-compat literal arm (an
|
||||||
/// a <see cref="LogLevel.Warning"/>. The <c>dev:</c> prefix is the explicit
|
/// unprefixed cleartext API key in <c>DriverConfig</c> JSON) and a
|
||||||
/// opt-in path that doesn't warn.
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="secretRef">The secret reference string to resolve.</param>
|
/// <param name="secretRef">The secret reference string to resolve.</param>
|
||||||
|
/// <param name="resolver">The shared secret resolver used by the <c>secret:</c> arm.</param>
|
||||||
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
/// <param name="logger">Optional logger for warning on cleartext keys.</param>
|
||||||
|
/// <param name="ct">Cancellation token for the async <c>secret:</c> resolution.</param>
|
||||||
/// <returns>The resolved API-key string.</returns>
|
/// <returns>The resolved API-key string.</returns>
|
||||||
public static string ResolveApiKey(string secretRef, ILogger? logger = null)
|
public static async Task<string> ResolveApiKeyAsync(
|
||||||
|
string secretRef,
|
||||||
|
ISecretResolver resolver,
|
||||||
|
ILogger? logger = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
ArgumentException.ThrowIfNullOrEmpty(secretRef);
|
||||||
|
ArgumentNullException.ThrowIfNull(resolver);
|
||||||
|
|
||||||
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
if (secretRef.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -76,6 +90,19 @@ public static class GalaxySecretRef
|
|||||||
return secretRef[4..];
|
return secretRef[4..];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (secretRef.StartsWith("secret:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
// Production path: resolve the name through the shared encrypted secret store.
|
||||||
|
// Fail-closed — an absent/tombstoned secret throws rather than falling through
|
||||||
|
// to the literal arm (which would silently treat the ref string as the key).
|
||||||
|
var name = secretRef["secret:".Length..];
|
||||||
|
var value = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
|
||||||
|
return !string.IsNullOrEmpty(value)
|
||||||
|
? value
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
$"Galaxy.Gateway.ApiKeySecretRef='{secretRef}' resolves secret '{name}', but it is absent from the store (fail-closed).");
|
||||||
|
}
|
||||||
|
|
||||||
// Back-compat literal arm. An unprefixed string is treated as the literal
|
// 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
|
// 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
|
// cleartext key into DriverConfig sees it. Use the dev: prefix to suppress
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -43,6 +44,12 @@ public sealed class GalaxyDriver
|
|||||||
private readonly GalaxyDriverOptions _options;
|
private readonly GalaxyDriverOptions _options;
|
||||||
private readonly ILogger<GalaxyDriver> _logger;
|
private readonly ILogger<GalaxyDriver> _logger;
|
||||||
|
|
||||||
|
// Resolves the Gateway.ApiKeySecretRef secret: arm through the shared encrypted store.
|
||||||
|
// Injected via ctor (the production factory pulls it from DI). The internal test ctor
|
||||||
|
// defaults it to a null-object resolver so the 45 seam-injecting test call sites keep
|
||||||
|
// compiling; those tests never use a secret: ref (they inject seams or use env/file/literal).
|
||||||
|
private readonly ISecretResolver _secretResolver;
|
||||||
|
|
||||||
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
// PR 4.1 — IGalaxyHierarchySource is the test seam for browse. When null, the driver
|
||||||
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
// lazily builds a GatewayGalaxyHierarchySource around a GalaxyRepositoryClient on
|
||||||
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
// first DiscoverAsync. Tests inject a fake source via the internal ctor to exercise
|
||||||
@@ -147,14 +154,17 @@ public sealed class GalaxyDriver
|
|||||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for this driver instance.</param>
|
||||||
/// <param name="options">The Galaxy driver configuration options.</param>
|
/// <param name="options">The Galaxy driver configuration options.</param>
|
||||||
|
/// <param name="secretResolver">Resolves the <c>secret:</c> arm of <c>Gateway.ApiKeySecretRef</c>.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
public GalaxyDriver(
|
public GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null)
|
||||||
: this(driverInstanceId, options,
|
: this(driverInstanceId, options,
|
||||||
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
hierarchySource: null, dataReader: null, dataWriter: null, subscriber: null,
|
||||||
alarmAcknowledger: null, alarmFeed: null, logger)
|
alarmAcknowledger: null, alarmFeed: null, logger,
|
||||||
|
secretResolver: secretResolver ?? throw new ArgumentNullException(nameof(secretResolver)))
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +183,11 @@ public sealed class GalaxyDriver
|
|||||||
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
/// <param name="alarmAcknowledger">Optional custom alarm acknowledger for testing.</param>
|
||||||
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
/// <param name="alarmFeed">Optional custom alarm feed for testing.</param>
|
||||||
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
/// <param name="logger">Optional logger instance for diagnostics.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// Optional secret resolver for the <c>secret:</c> API-key arm. Defaults to a
|
||||||
|
/// null-object resolver (returns null for every name) so seam-injecting tests that
|
||||||
|
/// don't exercise a <c>secret:</c> ref need not supply one.
|
||||||
|
/// </param>
|
||||||
internal GalaxyDriver(
|
internal GalaxyDriver(
|
||||||
string driverInstanceId,
|
string driverInstanceId,
|
||||||
GalaxyDriverOptions options,
|
GalaxyDriverOptions options,
|
||||||
@@ -182,13 +197,15 @@ public sealed class GalaxyDriver
|
|||||||
IGalaxySubscriber? subscriber = null,
|
IGalaxySubscriber? subscriber = null,
|
||||||
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
IGalaxyAlarmAcknowledger? alarmAcknowledger = null,
|
||||||
IGalaxyAlarmFeed? alarmFeed = null,
|
IGalaxyAlarmFeed? alarmFeed = null,
|
||||||
ILogger<GalaxyDriver>? logger = null)
|
ILogger<GalaxyDriver>? logger = null,
|
||||||
|
ISecretResolver? secretResolver = null)
|
||||||
{
|
{
|
||||||
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
_driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId)
|
||||||
? driverInstanceId
|
? driverInstanceId
|
||||||
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
: throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId));
|
||||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||||
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
_logger = logger ?? NullLogger<GalaxyDriver>.Instance;
|
||||||
|
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||||||
_hierarchySource = hierarchySource;
|
_hierarchySource = hierarchySource;
|
||||||
_dataReader = dataReader;
|
_dataReader = dataReader;
|
||||||
_dataWriter = dataWriter;
|
_dataWriter = dataWriter;
|
||||||
@@ -316,7 +333,7 @@ public sealed class GalaxyDriver
|
|||||||
_driverInstanceId);
|
_driverInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
StartDeployWatcher();
|
await StartDeployWatcherAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
"GalaxyDriver {InstanceId} initialized — endpoint={Endpoint} clientName={ClientName}",
|
||||||
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
_driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName);
|
||||||
@@ -331,7 +348,7 @@ public sealed class GalaxyDriver
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
private async Task BuildProductionRuntimeAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
_ownedMxClient = MxGatewayClient.Create(clientOptions);
|
||||||
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
_ownedMxSession = new GalaxyMxSession(_options.MxAccess, _logger);
|
||||||
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.ConnectAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
@@ -386,7 +403,7 @@ public sealed class GalaxyDriver
|
|||||||
private async Task ReopenAsync(CancellationToken cancellationToken)
|
private async Task ReopenAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_ownedMxSession is null) return;
|
if (_ownedMxSession is null) return;
|
||||||
var clientOptions = BuildClientOptions(_options.Gateway);
|
var clientOptions = await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false);
|
||||||
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
await _ownedMxSession.RecreateAsync(clientOptions, cancellationToken).ConfigureAwait(false);
|
||||||
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
// The recreated session invalidates every prior gw item handle; drop the writer's handle/advise
|
||||||
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
// caches so the next write re-AddItems + re-AdviseSupervisory against the fresh session.
|
||||||
@@ -527,32 +544,42 @@ public sealed class GalaxyDriver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MxGatewayClientOptions BuildClientOptions(GalaxyGatewayOptions gw) => new()
|
private async Task<MxGatewayClientOptions> BuildClientOptionsAsync(
|
||||||
|
GalaxyGatewayOptions gw, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
// Resolve the API-key ref BEFORE the object initializer — the secret: arm is
|
||||||
// Pass the logger so the literal-arm cleartext fallback surfaces a startup
|
// async and you can't await inside an initializer. Pass the logger so the
|
||||||
// warning rather than silently shipping the key. The resolver lives in
|
// literal-arm cleartext fallback surfaces a startup warning rather than
|
||||||
// Driver.Galaxy.Contracts (GalaxySecretRef) so the runtime driver and the
|
// silently shipping the key. The resolver lives in Driver.Galaxy.Contracts
|
||||||
// AdminUI browser share one implementation.
|
// (GalaxySecretRef) so the runtime driver and the AdminUI browser share one
|
||||||
ApiKey = GalaxySecretRef.ResolveApiKey(gw.ApiKeySecretRef, _logger),
|
// implementation; the secret: arm resolves through the shared ISecretResolver.
|
||||||
UseTls = gw.UseTls,
|
var apiKey = await GalaxySecretRef
|
||||||
CaCertificatePath = gw.CaCertificatePath,
|
.ResolveApiKeyAsync(gw.ApiKeySecretRef, _secretResolver, _logger, cancellationToken)
|
||||||
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
.ConfigureAwait(false);
|
||||||
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
return new MxGatewayClientOptions
|
||||||
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
{
|
||||||
};
|
Endpoint = new Uri(gw.Endpoint, UriKind.Absolute),
|
||||||
|
ApiKey = apiKey,
|
||||||
|
UseTls = gw.UseTls,
|
||||||
|
CaCertificatePath = gw.CaCertificatePath,
|
||||||
|
ConnectTimeout = TimeSpan.FromSeconds(gw.ConnectTimeoutSeconds),
|
||||||
|
DefaultCallTimeout = TimeSpan.FromSeconds(gw.DefaultCallTimeoutSeconds),
|
||||||
|
StreamTimeout = gw.StreamTimeoutSeconds > 0 ? TimeSpan.FromSeconds(gw.StreamTimeoutSeconds) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void StartDeployWatcher()
|
private async Task StartDeployWatcherAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!_options.Repository.WatchDeployEvents) return;
|
if (!_options.Repository.WatchDeployEvents) return;
|
||||||
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
if (_ownedRepositoryClient is null && _hierarchySource is null) return;
|
||||||
|
|
||||||
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
// Reuse the lazily-built repository client (DiscoverAsync constructs it on demand).
|
||||||
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
// If discovery hasn't run yet, build the client here so the watcher has a target.
|
||||||
// Guard with ??= so if BuildDefaultHierarchySource later runs it reuses this client
|
// Build under a null-check (not ??=) so if BuildDefaultHierarchySourceAsync later
|
||||||
// rather than overwriting the field and leaking the first instance.
|
// runs it reuses this client rather than overwriting the field and leaking the
|
||||||
|
// first instance — the client-options build is now async (secret: arm).
|
||||||
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
_ownedRepositoryClient ??= ZB.MOM.WW.MxGateway.Client.GalaxyRepositoryClient.Create(
|
||||||
BuildClientOptions(_options.Gateway));
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
|
|
||||||
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
var source = new GatewayGalaxyDeployWatchSource(_ownedRepositoryClient);
|
||||||
_deployWatcher = new DeployWatcher(source, _logger);
|
_deployWatcher = new DeployWatcher(source, _logger);
|
||||||
@@ -691,7 +718,8 @@ public sealed class GalaxyDriver
|
|||||||
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
// After discovery, SyncPlatformsAsync refreshes the probe watcher's membership so
|
||||||
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
// newly added $WinPlatform / $AppEngine objects start advising their ScanState attribute.
|
||||||
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
var capturingBuilder = new SecurityCapturingBuilder(builder, _securityByFullRef);
|
||||||
var source = _hierarchySource ??= BuildDefaultHierarchySource();
|
var source = _hierarchySource ??=
|
||||||
|
await BuildDefaultHierarchySourceAsync(cancellationToken).ConfigureAwait(false);
|
||||||
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
// Thread the v3 reverse resolver so the discoverer emits each authored attribute's RawPath as
|
||||||
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
// its node reference (FullName) — the security map SecurityCapturingBuilder captures is then
|
||||||
// keyed by RawPath, matching what WriteAsync resolves against.
|
// keyed by RawPath, matching what WriteAsync resolves against.
|
||||||
@@ -1292,12 +1320,14 @@ public sealed class GalaxyDriver
|
|||||||
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
/// <see cref="Dispose"/>. Tests bypass this by injecting their own source via the
|
||||||
/// internal ctor.
|
/// internal ctor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private IGalaxyHierarchySource BuildDefaultHierarchySource()
|
private async Task<IGalaxyHierarchySource> BuildDefaultHierarchySourceAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Reuse a client that StartDeployWatcher may have already created (??=) rather
|
// Reuse a client that StartDeployWatcherAsync may have already created (??=) rather
|
||||||
// than always overwriting the field and leaking the first instance. Both paths
|
// than always overwriting the field and leaking the first instance. Both paths
|
||||||
// produce equivalent clients from the same options.
|
// produce equivalent clients from the same options. The client-options build is
|
||||||
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(BuildClientOptions(_options.Gateway));
|
// async now (secret: arm resolves through the shared ISecretResolver).
|
||||||
|
_ownedRepositoryClient ??= GalaxyRepositoryClient.Create(
|
||||||
|
await BuildClientOptionsAsync(_options.Gateway, cancellationToken).ConfigureAwait(false));
|
||||||
return new TracedGalaxyHierarchySource(
|
return new TracedGalaxyHierarchySource(
|
||||||
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
new GatewayGalaxyHierarchySource(_ownedRepositoryClient), _options.MxAccess.ClientName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
@@ -23,30 +24,45 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
{
|
{
|
||||||
public const string DriverTypeName = "GalaxyMxGateway";
|
public const string DriverTypeName = "GalaxyMxGateway";
|
||||||
|
|
||||||
/// <summary>Registers the Galaxy driver factory with the given registry and optional logger factory.</summary>
|
/// <summary>Registers the Galaxy driver factory with the given registry, secret resolver, and optional logger factory.</summary>
|
||||||
/// <param name="registry">The driver factory registry to register with.</param>
|
/// <param name="registry">The driver factory registry to register with.</param>
|
||||||
|
/// <param name="secretResolver">
|
||||||
|
/// The shared secret resolver (from DI) used to resolve the <c>secret:</c> arm of
|
||||||
|
/// each instance's <c>Gateway.ApiKeySecretRef</c>.
|
||||||
|
/// </param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
public static void Register(
|
||||||
|
DriverFactoryRegistry registry,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
|
ILoggerFactory? loggerFactory = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registry);
|
ArgumentNullException.ThrowIfNull(registry);
|
||||||
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
|
registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, secretResolver));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Convenience for tests + standalone callers.</summary>
|
/// <summary>
|
||||||
|
/// Convenience for tests + standalone callers. Uses the <see cref="NullSecretResolver"/>
|
||||||
|
/// null-object, so the <c>secret:</c> API-key arm resolves fail-closed (absent) — callers
|
||||||
|
/// that need a working <c>secret:</c> ref must use the <see cref="Register"/> path that
|
||||||
|
/// threads the DI resolver.
|
||||||
|
/// </summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson)
|
||||||
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
|
=> CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null, NullSecretResolver.Instance);
|
||||||
|
|
||||||
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory.</summary>
|
/// <summary>Creates a Galaxy driver instance from configuration JSON with optional logger factory and a secret resolver.</summary>
|
||||||
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
/// <param name="driverInstanceId">The unique identifier for the driver instance.</param>
|
||||||
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
/// <param name="driverConfigJson">The driver configuration in JSON format.</param>
|
||||||
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
/// <param name="loggerFactory">The optional logger factory for creating drivers.</param>
|
||||||
|
/// <param name="secretResolver">The secret resolver for the driver's <c>secret:</c> API-key arm.</param>
|
||||||
/// <returns>The constructed Galaxy driver instance.</returns>
|
/// <returns>The constructed Galaxy driver instance.</returns>
|
||||||
public static GalaxyDriver CreateInstance(
|
public static GalaxyDriver CreateInstance(
|
||||||
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
|
string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ISecretResolver secretResolver)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(secretResolver);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
|
||||||
|
|
||||||
@@ -88,7 +104,7 @@ public static class GalaxyDriverFactoryExtensions
|
|||||||
RawTags = dto.RawTags ?? [],
|
RawTags = dto.RawTags ?? [],
|
||||||
};
|
};
|
||||||
|
|
||||||
return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger<GalaxyDriver>());
|
return new GalaxyDriver(driverInstanceId, options, secretResolver, loggerFactory?.CreateLogger<GalaxyDriver>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A null-object <see cref="ISecretResolver"/> that resolves every name to <c>null</c>
|
||||||
|
/// (absent). Used as the default for the internal test ctor and the parse-only
|
||||||
|
/// <c>CreateInstance</c> path so callers that never exercise a <c>secret:</c> API-key
|
||||||
|
/// ref need not thread a real resolver. A production <c>GalaxyDriver</c> always receives
|
||||||
|
/// the DI-registered resolver via the factory; a <c>secret:</c> ref resolved against this
|
||||||
|
/// null object throws fail-closed (the secret is reported absent), which is the correct
|
||||||
|
/// behaviour for a mis-wired deployment.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class NullSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <summary>The shared singleton instance.</summary>
|
||||||
|
public static readonly NullSecretResolver Instance = new();
|
||||||
|
|
||||||
|
private NullSecretResolver()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
namespace ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||||
|
|
||||||
@@ -48,7 +49,10 @@ public static class DriverFactoryBootstrap
|
|||||||
// The calc driver needs the root script logger so a script failure fans out onto the
|
// The calc driver needs the root script logger so a script failure fans out onto the
|
||||||
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
// script-logs topic; resolve it here (null on nodes without the script pipeline wired).
|
||||||
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
var scriptRoot = sp.GetService<ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger>();
|
||||||
Register(registry, loggerFactory, scriptRoot);
|
// The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the
|
||||||
|
// shared ISecretResolver (registered unconditionally by AddZbSecrets on every node).
|
||||||
|
var secretResolver = sp.GetRequiredService<ISecretResolver>();
|
||||||
|
Register(registry, loggerFactory, secretResolver, scriptRoot);
|
||||||
return registry;
|
return registry;
|
||||||
});
|
});
|
||||||
services.AddSingleton<IDriverFactory>(sp =>
|
services.AddSingleton<IDriverFactory>(sp =>
|
||||||
@@ -131,13 +135,14 @@ public static class DriverFactoryBootstrap
|
|||||||
private static void Register(
|
private static void Register(
|
||||||
DriverFactoryRegistry registry,
|
DriverFactoryRegistry registry,
|
||||||
ILoggerFactory? loggerFactory,
|
ILoggerFactory? loggerFactory,
|
||||||
|
ISecretResolver secretResolver,
|
||||||
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null)
|
||||||
{
|
{
|
||||||
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry);
|
||||||
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot);
|
||||||
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry);
|
||||||
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory);
|
||||||
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
Driver.OpcUaClient.OpcUaClientDriverFactoryExtensions.Register(registry, loggerFactory);
|
||||||
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
Driver.S7.S7DriverFactoryExtensions.Register(registry);
|
||||||
|
|||||||
+12
-1
@@ -1,5 +1,6 @@
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
||||||
|
|
||||||
@@ -14,7 +15,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests;
|
|||||||
[Trait("Category", "Unit")]
|
[Trait("Category", "Unit")]
|
||||||
public sealed class GalaxyDriverBrowserTests
|
public sealed class GalaxyDriverBrowserTests
|
||||||
{
|
{
|
||||||
private readonly GalaxyDriverBrowser _sut = new();
|
// These unit tests exercise only the pre-connect validation paths (empty endpoint,
|
||||||
|
// blank client name) that throw BEFORE the API-key secret: arm is resolved, so a
|
||||||
|
// null-returning stub resolver suffices — it is never consulted.
|
||||||
|
private readonly GalaxyDriverBrowser _sut = new(new StubSecretResolver());
|
||||||
|
|
||||||
|
/// <summary>A no-op resolver: every name resolves to null. Never consulted by these tests.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
/// <summary>The DriverType key must match the AdminUI's persisted "GalaxyMxGateway" value
|
||||||
/// so the factory wire-up picks the right browser implementation.</summary>
|
/// so the factory wire-up picks the right browser implementation.</summary>
|
||||||
|
|||||||
+94
-27
@@ -2,35 +2,48 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Shouldly;
|
using Shouldly;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Follow-up #2 — pins the three resolution forms supported by
|
/// Follow-up #2 + G-2a — pins the five resolution forms supported by
|
||||||
/// <see cref="GalaxySecretRef.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
/// <see cref="GalaxySecretRef.ResolveApiKeyAsync"/>: <c>env:NAME</c>, <c>file:PATH</c>,
|
||||||
/// and the literal-string fallback. A future DPAPI arm slots in here without
|
/// <c>dev:KEY</c>, <c>secret:NAME</c> (via the shared <see cref="ISecretResolver"/>), and
|
||||||
/// touching the call site. (The resolver was extracted from <c>GalaxyDriver</c> to
|
/// the literal-string fallback. The <c>secret:</c> arm is fail-closed — an absent secret
|
||||||
/// the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the runtime
|
/// throws rather than leaking the ref string as the key. (The resolver was extracted from
|
||||||
/// driver and the AdminUI browser share one copy.)
|
/// <c>GalaxyDriver</c> to the shared <c>GalaxySecretRef</c> in Driver.Galaxy.Contracts so the
|
||||||
|
/// runtime driver and the AdminUI browser share one copy.)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyDriverApiKeyResolverTests
|
public sealed class GalaxyDriverApiKeyResolverTests
|
||||||
{
|
{
|
||||||
|
/// <summary>Name the fake resolver knows; matches the SecretName-normalized form.</summary>
|
||||||
|
private const string KnownSecretName = "galaxy/inst1/apikey";
|
||||||
|
|
||||||
|
/// <summary>The value the fake resolver returns for <see cref="KnownSecretName"/>.</summary>
|
||||||
|
private const string KnownSecretValue = "key-from-secret-store";
|
||||||
|
|
||||||
|
/// <summary>A fake resolver: returns a known value for one known name, null otherwise.</summary>
|
||||||
|
private static ISecretResolver FakeResolver() => new StubSecretResolver();
|
||||||
|
|
||||||
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
/// <summary>Verifies that a literal string is returned unchanged.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_is_returned_unchanged()
|
public async Task Literal_string_is_returned_unchanged()
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
|
(await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver()))
|
||||||
|
.ShouldBe("plain-text-key");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_resolves_to_environment_variable()
|
public async Task Env_prefix_resolves_to_environment_variable()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
|
||||||
Environment.SetEnvironmentVariable(name, "key-from-env");
|
Environment.SetEnvironmentVariable(name, "key-from-env");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-env");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -40,26 +53,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_unset_variable_throws_with_descriptive_message()
|
public async Task Env_prefix_unset_variable_throws_with_descriptive_message()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
|
||||||
Environment.SetEnvironmentVariable(name, null);
|
Environment.SetEnvironmentVariable(name, null);
|
||||||
|
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(name);
|
ex.Message.ShouldContain(name);
|
||||||
ex.Message.ShouldContain("unset");
|
ex.Message.ShouldContain("unset");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_resolves_to_trimmed_file_contents()
|
public async Task File_prefix_resolves_to_trimmed_file_contents()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " key-from-file \n");
|
File.WriteAllText(path, " key-from-file \n");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
|
(await GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()))
|
||||||
|
.ShouldBe("key-from-file");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -69,26 +83,60 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
/// <summary>Verifies that file: prefix with missing path throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_missing_path_throws()
|
public async Task File_prefix_missing_path_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain(path);
|
ex.Message.ShouldContain(path);
|
||||||
ex.Message.ShouldContain("doesn't exist");
|
ex.Message.ShouldContain("doesn't exist");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== G-2a: secret: arm — resolves through the shared ISecretResolver, fail-closed =====
|
||||||
|
|
||||||
|
/// <summary>Verifies that secret: prefix resolves through the ISecretResolver.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_resolves_through_secret_resolver()
|
||||||
|
{
|
||||||
|
(await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver()))
|
||||||
|
.ShouldBe(KnownSecretValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that an absent secret throws (fail-closed) and does NOT leak the ref string.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_absent_secret_throws_fail_closed()
|
||||||
|
{
|
||||||
|
const string missingRef = "secret:galaxy/missing/apikey";
|
||||||
|
|
||||||
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync(missingRef, FakeResolver()));
|
||||||
|
|
||||||
|
// Fail-closed: must throw, must reference the secret + "absent"/"fail-closed", and
|
||||||
|
// must NOT return the ref string as the key.
|
||||||
|
ex.Message.ShouldContain("galaxy/missing/apikey");
|
||||||
|
ex.Message.ShouldContain("absent");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that the secret: arm does not consult the logger's literal warning.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Secret_prefix_does_not_emit_literal_warning()
|
||||||
|
{
|
||||||
|
var logger = new CaptureLogger();
|
||||||
|
await GalaxySecretRef.ResolveApiKeyAsync($"secret:{KnownSecretName}", FakeResolver(), logger);
|
||||||
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
|
||||||
|
|
||||||
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Literal_string_emits_warning_when_logger_supplied()
|
public async Task Literal_string_emits_warning_when_logger_supplied()
|
||||||
{
|
{
|
||||||
// A literal API key on a production deployment means the cleartext key sits
|
// A literal API key on a production deployment means the cleartext key sits
|
||||||
// in the DriverConfig JSON. The resolver must surface a warning so an
|
// in the DriverConfig JSON. The resolver must surface a warning so an
|
||||||
// operator who committed one by accident sees it at startup.
|
// operator who committed one by accident sees it at startup.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldContain(e =>
|
logger.Entries.ShouldContain(e =>
|
||||||
@@ -97,13 +145,13 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Dev_prefix_returns_literal_without_warning()
|
public async Task Dev_prefix_returns_literal_without_warning()
|
||||||
{
|
{
|
||||||
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
// An explicit dev: prefix signals the operator knowingly opted into a literal
|
||||||
// key (dev / parity rig). The resolver must accept it AND suppress the
|
// key (dev / parity rig). The resolver must accept it AND suppress the
|
||||||
// warning so production logs aren't polluted on a deliberate dev choice.
|
// warning so production logs aren't polluted on a deliberate dev choice.
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
var key = GalaxySecretRef.ResolveApiKey("dev:plain-text-key", logger);
|
var key = await GalaxySecretRef.ResolveApiKeyAsync("dev:plain-text-key", FakeResolver(), logger);
|
||||||
|
|
||||||
key.ShouldBe("plain-text-key");
|
key.ShouldBe("plain-text-key");
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
@@ -111,14 +159,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
|
|
||||||
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Env_prefix_does_not_emit_literal_warning()
|
public async Task Env_prefix_does_not_emit_literal_warning()
|
||||||
{
|
{
|
||||||
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_NOWARN";
|
||||||
Environment.SetEnvironmentVariable(name, "v");
|
Environment.SetEnvironmentVariable(name, "v");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var logger = new CaptureLogger();
|
var logger = new CaptureLogger();
|
||||||
GalaxySecretRef.ResolveApiKey($"env:{name}", logger);
|
await GalaxySecretRef.ResolveApiKeyAsync($"env:{name}", FakeResolver(), logger);
|
||||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -127,6 +175,14 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that a null resolver is rejected (ArgumentNullException).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Null_resolver_throws()
|
||||||
|
{
|
||||||
|
await Should.ThrowAsync<ArgumentNullException>(() =>
|
||||||
|
GalaxySecretRef.ResolveApiKeyAsync("plain-text-key", resolver: null!));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A test logger that captures log entries for verification.</summary>
|
/// <summary>A test logger that captures log entries for verification.</summary>
|
||||||
private sealed class CaptureLogger : ILogger
|
private sealed class CaptureLogger : ILogger
|
||||||
{
|
{
|
||||||
@@ -144,16 +200,27 @@ public sealed class GalaxyDriverApiKeyResolverTests
|
|||||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake ISecretResolver returning one known value; null for every other name.</summary>
|
||||||
|
private sealed class StubSecretResolver : ISecretResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<string?> GetAsync(SecretName name, CancellationToken ct) =>
|
||||||
|
Task.FromResult<string?>(
|
||||||
|
string.Equals(name.Value, KnownSecretName, StringComparison.Ordinal)
|
||||||
|
? KnownSecretValue
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
/// <summary>Verifies that file: prefix with empty file throws.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void File_prefix_empty_file_throws()
|
public async Task File_prefix_empty_file_throws()
|
||||||
{
|
{
|
||||||
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
|
||||||
File.WriteAllText(path, " \n ");
|
File.WriteAllText(path, " \n ");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ex = Should.Throw<InvalidOperationException>(() =>
|
var ex = await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||||
GalaxySecretRef.ResolveApiKey($"file:{path}"));
|
GalaxySecretRef.ResolveApiKeyAsync($"file:{path}", FakeResolver()));
|
||||||
ex.Message.ShouldContain("empty");
|
ex.Message.ShouldContain("empty");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public sealed class GalaxyDriverFactoryTests
|
|||||||
public void Register_AddsFactoryToRegistry()
|
public void Register_AddsFactoryToRegistry()
|
||||||
{
|
{
|
||||||
var registry = new DriverFactoryRegistry();
|
var registry = new DriverFactoryRegistry();
|
||||||
GalaxyDriverFactoryExtensions.Register(registry);
|
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||||||
|
|
||||||
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ public sealed class GalaxyDriverReadTests
|
|||||||
// _dataReader and _subscriber are both null. The follow-up read path can't
|
// _dataReader and _subscriber are both null. The follow-up read path can't
|
||||||
// synthesise a Read without one, so it surfaces a NotSupportedException
|
// synthesise a Read without one, so it surfaces a NotSupportedException
|
||||||
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
// pointing at the misuse rather than NullRef'ing inside the pump path.
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.ReadAsync(["x"], CancellationToken.None));
|
driver.ReadAsync(["x"], CancellationToken.None));
|
||||||
|
|||||||
+1
-1
@@ -242,7 +242,7 @@ public sealed class GalaxyDriverSubscribeTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeAsync_NoSubscriber_Throws()
|
public async Task SubscribeAsync_NoSubscriber_Throws()
|
||||||
{
|
{
|
||||||
using var driver = new GalaxyDriver("g", Opts());
|
using var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
|
||||||
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
ex.Message.ShouldContain("GalaxyDriver.SubscribeAsync requires");
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ public sealed class GalaxyDriverWriteTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteAsync_NoWriter_Throws()
|
public async Task WriteAsync_NoWriter_Throws()
|
||||||
{
|
{
|
||||||
var driver = new GalaxyDriver("g", Opts());
|
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null);
|
||||||
|
|
||||||
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
|
||||||
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
|
||||||
|
|||||||
Reference in New Issue
Block a user