diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 7723ed6..d54070b 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -12,6 +12,7 @@ + @@ -50,6 +51,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs new file mode 100644 index 0000000..7362f54 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Config/GalaxyDriverOptions.cs @@ -0,0 +1,70 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +/// +/// Driver-instance options for the in-process .NET 10 Galaxy driver. Maps to the +/// DriverConfig JSON column on the central config DB. Decomposed into nested +/// records so the JSON structure mirrors the runtime shape and operators can target +/// individual sections (gateway endpoint, mxaccess client identity, reconnect policy) +/// without touching the rest. +/// +/// Connection details for the MxAccess gateway (mxaccessgw repo). +/// MXAccess-specific knobs surfaced through gw — client name, publishing interval, write-user. +/// Galaxy Repository browse options consumed by the discoverer. +/// Backoff knobs for the in-driver reconnect supervisor (PR 4.5). +public sealed record GalaxyDriverOptions( + GalaxyGatewayOptions Gateway, + GalaxyMxAccessOptions MxAccess, + GalaxyRepositoryOptions Repository, + GalaxyReconnectOptions Reconnect); + +/// +/// Connection details for the MxAccess gateway. resolves +/// through the server-side secret store (DPAPI for production, environment override for +/// dev) — the API key never appears in cleartext config. +/// +public sealed record GalaxyGatewayOptions( + string Endpoint, + string ApiKeySecretRef, + bool UseTls = true, + string? CaCertificatePath = null, + int ConnectTimeoutSeconds = 10, + int DefaultCallTimeoutSeconds = 5, + int StreamTimeoutSeconds = 0); + +/// +/// MXAccess-specific knobs the gateway forwards to the worker process. +/// +/// +/// Wonderware client identity. MUST be unique per OtOpcUa instance — when two instances +/// share a name, the older session loses subscription state. Redundancy pairs (decision +/// #149) enforce uniqueness via install scripts. +/// +/// +/// Hint forwarded as buffered_update_interval_ms on subscribe; lets the worker +/// coalesce updates at the OPC UA publishing cadence rather than every COM tick. +/// +/// +/// Reserved for ArchestrA secured-write user mapping; PR 4.3 wires WriteSecured +/// routing against this id. 0 = anonymous. +/// +public sealed record GalaxyMxAccessOptions( + string ClientName, + int PublishingIntervalMs = 1000, + int WriteUserId = 0); + +/// +/// Galaxy Repository browse-side knobs consumed by PR 4.1's GalaxyDiscoverer. +/// +public sealed record GalaxyRepositoryOptions( + int DiscoverPageSize = 5000, + bool WatchDeployEvents = true); + +/// +/// Backoff knobs for the in-driver reconnect supervisor (PR 4.5). Replay-on-session-lost +/// calls the gw's ReplaySubscriptions RPC after reconnect rather than re-issuing +/// subscribe-bulk for every tag. +/// +public sealed record GalaxyReconnectOptions( + int InitialBackoffMs = 500, + int MaxBackoffMs = 30_000, + bool ReplayOnSessionLost = true); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs new file mode 100644 index 0000000..e09fb9f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriver.cs @@ -0,0 +1,102 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// In-process .NET 10 Galaxy driver — the v2 replacement for the Galaxy.Host / +/// Galaxy.Proxy pair. PR 4.0 ships the project skeleton with +/// bodies that wire to a future IGalaxyGatewayClient abstraction. Capability +/// interfaces (browse, read, write, subscribe, history routing, host probes) land in +/// PRs 4.1–4.7; the wiring sequence keeps every intermediate state buildable so the +/// Galaxy:Backend flag (PR 4.W) can flip between legacy-host and mxgateway +/// for parity testing. +/// +/// +/// This driver is registered as a Tier A in-process driver alongside Modbus / S7 / etc. +/// The legacy GalaxyProxyDriver (Driver.Galaxy.Proxy) coexists until PR 7.2; +/// registers under driver-type name +/// "GalaxyMxGateway" so both paths can be live simultaneously during parity testing. +/// +public sealed class GalaxyDriver : IDriver, IDisposable +{ + private readonly string _driverInstanceId; + private readonly GalaxyDriverOptions _options; + private readonly ILogger _logger; + + private DriverHealth _health = new(DriverState.Unknown, null, null); + private bool _disposed; + + public GalaxyDriver( + string driverInstanceId, + GalaxyDriverOptions options, + ILogger? logger = null) + { + _driverInstanceId = !string.IsNullOrWhiteSpace(driverInstanceId) + ? driverInstanceId + : throw new ArgumentException("Driver instance id required.", nameof(driverInstanceId)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public string DriverInstanceId => _driverInstanceId; + + /// + public string DriverType => GalaxyDriverFactoryExtensions.DriverTypeName; + + /// Test-visible options snapshot. + internal GalaxyDriverOptions Options => _options; + + /// + public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + // PR 4.0 skeleton — capability bodies (PRs 4.1-4.7) replace this stub with real + // MxGatewayClient session opening. The skeleton keeps the IDriver shape buildable + // so the Galaxy:Backend flag (PR 4.W) can register the driver factory now. + _logger.LogInformation( + "GalaxyDriver {InstanceId} initializing — endpoint={Endpoint} clientName={ClientName} (skeleton; real gateway connect in PR 4.1+)", + _driverInstanceId, _options.Gateway.Endpoint, _options.MxAccess.ClientName); + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); + return Task.CompletedTask; + } + + /// + public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) + { + // In-place config reapply. PR 4.5's reconnect supervisor will swap the + // gateway-client options under the lock; for the skeleton we just refresh health. + ObjectDisposedException.ThrowIf(_disposed, this); + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); + return Task.CompletedTask; + } + + /// + public Task ShutdownAsync(CancellationToken cancellationToken) + { + if (_disposed) return Task.CompletedTask; + _logger.LogInformation("GalaxyDriver {InstanceId} shutting down", _driverInstanceId); + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + return Task.CompletedTask; + } + + /// + public DriverHealth GetHealth() => _health; + + /// + public long GetMemoryFootprint() => 0; // PR 4.4 sets this from SubscriptionRegistry size. + + /// + public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + // No owned IDisposables until PR 4.2's GalaxyMxSession lands. + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs new file mode 100644 index 0000000..b9cc8ce --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/GalaxyDriverFactoryExtensions.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy; + +/// +/// Static factory registration helper for . Mirrors +/// GalaxyProxyDriverFactoryExtensions / ModbusDriverFactoryExtensions. +/// Server's Program.cs calls once at startup; the driver +/// bootstrap pipeline materialises DriverInstance rows whose DriverType matches +/// into live instances. +/// +/// +/// The driver-type name "GalaxyMxGateway" is intentionally distinct from the +/// legacy proxy's "Galaxy" so both factories can be registered simultaneously +/// during parity testing (Phase 5). PR 4.W will add a server-side Galaxy:Backend +/// switch that materialises a Galaxy DriverInstance under one or the other type name. +/// +public static class GalaxyDriverFactoryExtensions +{ + public const string DriverTypeName = "GalaxyMxGateway"; + + public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory)); + } + + /// Convenience for tests + standalone callers. + public static GalaxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null); + + public static GalaxyDriver CreateInstance( + string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + var dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions) + ?? throw new InvalidOperationException( + $"Galaxy driver config for '{driverInstanceId}' deserialised to null"); + + var options = new GalaxyDriverOptions( + Gateway: new GalaxyGatewayOptions( + Endpoint: dto.Gateway?.Endpoint + ?? throw new InvalidOperationException( + $"Galaxy driver '{driverInstanceId}' missing required Gateway.Endpoint"), + ApiKeySecretRef: dto.Gateway.ApiKeySecretRef + ?? throw new InvalidOperationException( + $"Galaxy driver '{driverInstanceId}' missing required Gateway.ApiKeySecretRef"), + UseTls: dto.Gateway.UseTls ?? true, + CaCertificatePath: dto.Gateway.CaCertificatePath, + ConnectTimeoutSeconds: dto.Gateway.ConnectTimeoutSeconds ?? 10, + DefaultCallTimeoutSeconds: dto.Gateway.DefaultCallTimeoutSeconds ?? 5, + StreamTimeoutSeconds: dto.Gateway.StreamTimeoutSeconds ?? 0), + MxAccess: new GalaxyMxAccessOptions( + ClientName: dto.MxAccess?.ClientName + ?? throw new InvalidOperationException( + $"Galaxy driver '{driverInstanceId}' missing required MxAccess.ClientName"), + PublishingIntervalMs: dto.MxAccess.PublishingIntervalMs ?? 1000, + WriteUserId: dto.MxAccess.WriteUserId ?? 0), + Repository: new GalaxyRepositoryOptions( + DiscoverPageSize: dto.Repository?.DiscoverPageSize ?? 5000, + WatchDeployEvents: dto.Repository?.WatchDeployEvents ?? true), + Reconnect: new GalaxyReconnectOptions( + InitialBackoffMs: dto.Reconnect?.InitialBackoffMs ?? 500, + MaxBackoffMs: dto.Reconnect?.MaxBackoffMs ?? 30_000, + ReplayOnSessionLost: dto.Reconnect?.ReplayOnSessionLost ?? true)); + + return new GalaxyDriver(driverInstanceId, options, loggerFactory?.CreateLogger()); + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + internal sealed class GalaxyDriverConfigDto + { + public GatewayDto? Gateway { get; init; } + public MxAccessDto? MxAccess { get; init; } + public RepositoryDto? Repository { get; init; } + public ReconnectDto? Reconnect { get; init; } + } + + internal sealed class GatewayDto + { + public string? Endpoint { get; init; } + public string? ApiKeySecretRef { get; init; } + public bool? UseTls { get; init; } + public string? CaCertificatePath { get; init; } + public int? ConnectTimeoutSeconds { get; init; } + public int? DefaultCallTimeoutSeconds { get; init; } + public int? StreamTimeoutSeconds { get; init; } + } + + internal sealed class MxAccessDto + { + public string? ClientName { get; init; } + public int? PublishingIntervalMs { get; init; } + public int? WriteUserId { get; init; } + } + + internal sealed class RepositoryDto + { + public int? DiscoverPageSize { get; init; } + public bool? WatchDeployEvents { get; init; } + } + + internal sealed class ReconnectDto + { + public int? InitialBackoffMs { get; init; } + public int? MaxBackoffMs { get; init; } + public bool? ReplayOnSessionLost { get; init; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj new file mode 100644 index 0000000..4a2812d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + AnyCPU;x64 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Galaxy + + + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs new file mode 100644 index 0000000..e0ffc6d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/GalaxyDriverFactoryTests.cs @@ -0,0 +1,166 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests; + +/// +/// Smoke tests for the PR 4.0 driver skeleton. The IDriver shape, factory parsing, +/// and lifecycle methods land in PR 4.0; capability bodies (browse / read / write / +/// subscribe / health forwarder / probe watcher) are tested in PRs 4.1–4.7 each +/// against their own seam. +/// +public sealed class GalaxyDriverFactoryTests +{ + private const string MinimalConfig = """ + { + "Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" }, + "MxAccess": { "ClientName": "OtOpcUa-A" } + } + """; + + [Fact] + public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-instance-a", MinimalConfig); + + driver.DriverInstanceId.ShouldBe("galaxy-instance-a"); + driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName); + driver.Options.Gateway.Endpoint.ShouldBe("https://mxgw.test:5001"); + driver.Options.Gateway.ApiKeySecretRef.ShouldBe("galaxy:apiKey"); + driver.Options.Gateway.UseTls.ShouldBeTrue(); + driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(10); + driver.Options.MxAccess.ClientName.ShouldBe("OtOpcUa-A"); + driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(1000); + driver.Options.Repository.DiscoverPageSize.ShouldBe(5000); + driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue(); + } + + [Fact] + public void CreateInstance_OverridesDefaults_FromFullConfig() + { + const string fullConfig = """ + { + "Gateway": { + "Endpoint": "https://mxgw.prod:5001", + "ApiKeySecretRef": "secret:abc", + "UseTls": false, + "CaCertificatePath": "C:/certs/ca.crt", + "ConnectTimeoutSeconds": 5, + "DefaultCallTimeoutSeconds": 3, + "StreamTimeoutSeconds": 60 + }, + "MxAccess": { + "ClientName": "OtOpcUa-Prod", + "PublishingIntervalMs": 250, + "WriteUserId": 17 + }, + "Repository": { "DiscoverPageSize": 1000, "WatchDeployEvents": false }, + "Reconnect": { "InitialBackoffMs": 100, "MaxBackoffMs": 5000, "ReplayOnSessionLost": false } + } + """; + + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-prod", fullConfig); + + driver.Options.Gateway.UseTls.ShouldBeFalse(); + driver.Options.Gateway.CaCertificatePath.ShouldBe("C:/certs/ca.crt"); + driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(5); + driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(250); + driver.Options.MxAccess.WriteUserId.ShouldBe(17); + driver.Options.Repository.DiscoverPageSize.ShouldBe(1000); + driver.Options.Repository.WatchDeployEvents.ShouldBeFalse(); + driver.Options.Reconnect.InitialBackoffMs.ShouldBe(100); + driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse(); + } + + [Fact] + public void CreateInstance_MissingEndpoint_Throws() + { + const string bad = """{"Gateway":{"ApiKeySecretRef":"x"},"MxAccess":{"ClientName":"y"}}"""; + Should.Throw( + () => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint"); + } + + [Fact] + public void CreateInstance_MissingApiKey_Throws() + { + const string bad = """{"Gateway":{"Endpoint":"x"},"MxAccess":{"ClientName":"y"}}"""; + Should.Throw( + () => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef"); + } + + [Fact] + public void CreateInstance_MissingClientName_Throws() + { + const string bad = """{"Gateway":{"Endpoint":"x","ApiKeySecretRef":"y"}}"""; + Should.Throw( + () => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("MxAccess.ClientName"); + } + + [Fact] + public void Register_AddsFactoryToRegistry() + { + var registry = new DriverFactoryRegistry(); + GalaxyDriverFactoryExtensions.Register(registry); + + registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName); + + var factory = registry.TryGet(GalaxyDriverFactoryExtensions.DriverTypeName); + factory.ShouldNotBeNull(); + + var driver = factory!.Invoke("galaxy-x", MinimalConfig); + driver.ShouldNotBeNull(); + driver.DriverInstanceId.ShouldBe("galaxy-x"); + driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName); + } + + [Fact] + public async Task DriverLifecycle_InitializeShutdown_ToggleHealth() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + + await driver.InitializeAsync(MinimalConfig, CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull(); + + await driver.ShutdownAsync(CancellationToken.None); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + + driver.GetMemoryFootprint().ShouldBe(0); + await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw + } + + [Fact] + public async Task ReinitializeAsync_RefreshesHealth() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + await driver.InitializeAsync(MinimalConfig, CancellationToken.None); + var firstStamp = driver.GetHealth().LastSuccessfulRead!.Value; + + // Force a measurable clock delta so the comparison is stable on fast machines. + await Task.Delay(20); + await driver.ReinitializeAsync(MinimalConfig, CancellationToken.None); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp); + } + + [Fact] + public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + driver.Dispose(); + Should.NotThrow(() => driver.Dispose()); + } + + [Fact] + public async Task InitializeAfterDispose_Throws() + { + var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig); + driver.Dispose(); + await Should.ThrowAsync(() => + driver.InitializeAsync(MinimalConfig, CancellationToken.None)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj new file mode 100644 index 0000000..e5c3a06 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + +