diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs new file mode 100644 index 0000000..3edbfaf --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverFactoryRegistry.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Hosting; + +/// +/// Process-singleton registry of factories keyed by +/// DriverInstance.DriverType string. Each driver project ships a DI +/// extension (e.g. services.AddGalaxyProxyDriverFactory()) that registers +/// its factory at startup; the bootstrapper looks up the factory by +/// DriverInstance.DriverType + invokes it with the row's +/// DriverInstanceId + DriverConfig JSON. +/// +/// +/// Closes the gap surfaced by task #240 live smoke — DriverInstance rows in +/// the central config DB had no path to materialise as registered +/// instances. The factory registry is the seam. +/// +public sealed class DriverFactoryRegistry +{ + private readonly Dictionary> _factories + = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); + + /// + /// Register a factory for . Throws if a factory is + /// already registered for that type — drivers are singletons by type-name in + /// this process. + /// + /// Matches DriverInstance.DriverType. + /// + /// Receives (driverInstanceId, driverConfigJson); returns a new + /// . Must NOT call + /// itself — the bootstrapper calls it via + /// so the host's per-driver retry semantics apply uniformly. + /// + public void Register(string driverType, Func factory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverType); + ArgumentNullException.ThrowIfNull(factory); + lock (_lock) + { + if (_factories.ContainsKey(driverType)) + throw new InvalidOperationException( + $"DriverType '{driverType}' factory already registered for this process"); + _factories[driverType] = factory; + } + } + + /// + /// Try to look up the factory for . Returns null + /// if no driver assembly registered one — bootstrapper logs + skips so a + /// missing-assembly deployment doesn't take down the whole server. + /// + public Func? TryGet(string driverType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverType); + lock (_lock) return _factories.GetValueOrDefault(driverType); + } + + public IReadOnlyCollection RegisteredTypes + { + get { lock (_lock) return [.. _factories.Keys]; } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs new file mode 100644 index 0000000..a0a9ae8 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriverFactoryExtensions.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; + +/// +/// Static factory registration helper for . Server's +/// Program.cs calls once at startup; the bootstrapper (task #248) +/// then materialises Galaxy DriverInstance rows from the central config DB into live +/// driver instances. No dependency on Microsoft.Extensions.DependencyInjection so the +/// driver project stays free of DI machinery. +/// +public static class GalaxyProxyDriverFactoryExtensions +{ + public const string DriverTypeName = "Galaxy"; + + /// + /// Register the Galaxy driver factory in the supplied . + /// Throws if 'Galaxy' is already registered — single-instance per process. + /// + public static void Register(DriverFactoryRegistry registry) + { + ArgumentNullException.ThrowIfNull(registry); + registry.Register(DriverTypeName, CreateInstance); + } + + internal static GalaxyProxyDriver CreateInstance(string driverInstanceId, string driverConfigJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); + + // DriverConfig column is a JSON object that mirrors GalaxyProxyOptions. + // Required: PipeName, SharedSecret. Optional: ConnectTimeoutMs (defaults to 10s). + // The DriverInstanceId from the row wins over any value in the JSON — the row + // is the authoritative identity per the schema's UX_DriverInstance_Generation_LogicalId. + using var doc = JsonDocument.Parse(driverConfigJson); + var root = doc.RootElement; + + string pipeName = root.TryGetProperty("PipeName", out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString()! + : throw new InvalidOperationException( + $"GalaxyProxyDriver config for '{driverInstanceId}' missing required PipeName"); + string sharedSecret = root.TryGetProperty("SharedSecret", out var s) && s.ValueKind == JsonValueKind.String + ? s.GetString()! + : throw new InvalidOperationException( + $"GalaxyProxyDriver config for '{driverInstanceId}' missing required SharedSecret"); + var connectTimeout = root.TryGetProperty("ConnectTimeoutMs", out var t) && t.ValueKind == JsonValueKind.Number + ? TimeSpan.FromMilliseconds(t.GetInt32()) + : TimeSpan.FromSeconds(10); + + return new GalaxyProxyDriver(new GalaxyProxyOptions + { + DriverInstanceId = driverInstanceId, + PipeName = pipeName, + SharedSecret = sharedSecret, + ConnectTimeout = connectTimeout, + }); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj index 862f2da..be33f47 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj @@ -13,6 +13,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs new file mode 100644 index 0000000..640670f --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/DriverInstanceBootstrapper.cs @@ -0,0 +1,88 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Server; + +/// +/// Task #248 — bridges the gap surfaced by the Phase 7 live smoke (#240) where +/// DriverInstance rows in the central config DB had no path to materialise +/// as live instances in . +/// Called from OpcUaServerService.ExecuteAsync after the bootstrap loads +/// the published generation, before address-space build. +/// +/// +/// +/// Per row: looks up the DriverType string in +/// , calls the factory with the row's +/// DriverInstanceId + DriverConfig JSON to construct an +/// , then registers via +/// which invokes InitializeAsync +/// under the host's lifecycle semantics. +/// +/// +/// Unknown DriverType = factory not registered = log a warning and skip. +/// Per plan decision #12 (driver isolation), failure to construct or initialize +/// one driver doesn't prevent the rest from coming up — the Server keeps serving +/// the others' subtrees + the operator can fix the misconfigured row + republish +/// to retry. +/// +/// +public sealed class DriverInstanceBootstrapper( + DriverFactoryRegistry factories, + DriverHost driverHost, + IServiceScopeFactory scopeFactory, + ILogger logger) +{ + public async Task RegisterDriversFromGenerationAsync(long generationId, CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var rows = await db.DriverInstances.AsNoTracking() + .Where(d => d.GenerationId == generationId && d.Enabled) + .ToListAsync(ct).ConfigureAwait(false); + + var registered = 0; + var skippedUnknownType = 0; + var failedInit = 0; + + foreach (var row in rows) + { + var factory = factories.TryGet(row.DriverType); + if (factory is null) + { + logger.LogWarning( + "DriverInstance {Id} skipped — DriverType '{Type}' has no registered factory (known: {Known})", + row.DriverInstanceId, row.DriverType, string.Join(",", factories.RegisteredTypes)); + skippedUnknownType++; + continue; + } + + try + { + var driver = factory(row.DriverInstanceId, row.DriverConfig); + await driverHost.RegisterAsync(driver, row.DriverConfig, ct).ConfigureAwait(false); + registered++; + logger.LogInformation( + "DriverInstance {Id} ({Type}) registered + initialized", row.DriverInstanceId, row.DriverType); + } + catch (Exception ex) + { + // Plan decision #12 — driver isolation. Log + continue so one bad row + // doesn't deny the OPC UA endpoint to the rest of the fleet. + logger.LogError(ex, + "DriverInstance {Id} ({Type}) failed to initialize — driver state will reflect Faulted; operator can republish to retry", + row.DriverInstanceId, row.DriverType); + failedInit++; + } + } + + logger.LogInformation( + "DriverInstanceBootstrapper: gen={Gen} registered={Registered} skippedUnknownType={Skipped} failedInit={Failed}", + generationId, registered, skippedUnknownType, failedInit); + return registered; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs index 8b7705a..c988ee6 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -18,6 +18,7 @@ public sealed class OpcUaServerService( DriverHost driverHost, OpcUaApplicationHost applicationHost, DriverEquipmentContentRegistry equipmentContentRegistry, + DriverInstanceBootstrapper driverBootstrapper, Phase7Composer phase7Composer, IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService @@ -37,6 +38,13 @@ public sealed class OpcUaServerService( // address space until the first publish, then the registry fills on next restart. if (result.GenerationId is { } gen) { + // Task #248 — register IDriver instances from the published DriverInstance + // rows BEFORE the equipment-content load + Phase 7 compose, so the rest of + // the pipeline sees a populated DriverHost. Without this step Phase 7's + // CachedTagUpstreamSource has no upstream feed + virtual-tag scripts read + // BadNodeIdUnknown for every tag path (gap surfaced by task #240 smoke). + await driverBootstrapper.RegisterDriversFromGenerationAsync(gen, stoppingToken); + await PopulateEquipmentContentAsync(gen, stoppingToken); // Phase 7 follow-up #246 — load Script + VirtualTag + ScriptedAlarm rows, diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs index 2cd69a4..956f317 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; using ZB.MOM.WW.OtOpcUa.Core.Hosting; using ZB.MOM.WW.OtOpcUa.Core.AlarmHistorian; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy; using ZB.MOM.WW.OtOpcUa.Server; using ZB.MOM.WW.OtOpcUa.Server.OpcUa; using ZB.MOM.WW.OtOpcUa.Server.Phase7; @@ -89,6 +90,18 @@ builder.Services.AddSingleton(_ => new LiteDbConfigCache(opti builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Task #248 — driver-instance bootstrap pipeline. DriverFactoryRegistry is the +// type-name → factory map; each driver project's static Register call pre-loads +// its factory so the bootstrapper can materialise DriverInstance rows from the +// central DB into live IDriver instances. +builder.Services.AddSingleton(_ => +{ + var registry = new DriverFactoryRegistry(); + GalaxyProxyDriverFactoryExtensions.Register(registry); + return registry; +}); +builder.Services.AddSingleton(); + // ADR-001 Option A wiring — the registry is the handoff between OpcUaServerService's // bootstrap-time population pass + OpcUaApplicationHost's StartAsync walker invocation. // DriverEquipmentContentRegistry.Get is the equipmentContentLookup delegate that PR #155 diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj index 9d870a1..c8aa856 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -34,6 +34,7 @@ + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db b/src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db new file mode 100644 index 0000000..ee9bad0 Binary files /dev/null and b/src/ZB.MOM.WW.OtOpcUa.Server/config_cache.db differ diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs new file mode 100644 index 0000000..22ad180 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverFactoryRegistryTests.cs @@ -0,0 +1,73 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Task #248 — covers the contract that +/// consumes. +/// +[Trait("Category", "Unit")] +public sealed class DriverFactoryRegistryTests +{ + private static IDriver FakeDriver(string id, string config) => new FakeIDriver(id); + + [Fact] + public void Register_then_TryGet_returns_factory() + { + var r = new DriverFactoryRegistry(); + r.Register("MyDriver", FakeDriver); + + r.TryGet("MyDriver").ShouldNotBeNull(); + r.TryGet("Nope").ShouldBeNull(); + } + + [Fact] + public void Register_is_case_insensitive() + { + var r = new DriverFactoryRegistry(); + r.Register("Galaxy", FakeDriver); + r.TryGet("galaxy").ShouldNotBeNull(); + r.TryGet("GALAXY").ShouldNotBeNull(); + } + + [Fact] + public void Register_duplicate_type_throws() + { + var r = new DriverFactoryRegistry(); + r.Register("Galaxy", FakeDriver); + Should.Throw(() => r.Register("Galaxy", FakeDriver)); + } + + [Fact] + public void Register_null_args_rejected() + { + var r = new DriverFactoryRegistry(); + Should.Throw(() => r.Register("", FakeDriver)); + Should.Throw(() => r.Register("X", null!)); + } + + [Fact] + public void RegisteredTypes_returns_snapshot() + { + var r = new DriverFactoryRegistry(); + r.Register("A", FakeDriver); + r.Register("B", FakeDriver); + r.RegisteredTypes.ShouldContain("A"); + r.RegisteredTypes.ShouldContain("B"); + } + + private sealed class FakeIDriver(string id) : IDriver + { + public string DriverInstanceId => id; + public string DriverType => "Fake"; + public Task InitializeAsync(string _, CancellationToken __) => Task.CompletedTask; + public Task ReinitializeAsync(string _, CancellationToken __) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken _) => Task.CompletedTask; + public Task FlushOptionalCachesAsync(CancellationToken _) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, null, null); + public long GetMemoryFootprint() => 0; + } +}