From 2e10cbe42d02ee3b6367ea69a31708bb886a9652 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 23 May 2026 15:38:27 -0400 Subject: [PATCH] feat(host): add NodeName to NodeOptions + INodeIdentityProvider - NodeName: semantic role-within-cluster identifier (node-a/node-b on sites, central-a/central-b on central). Bound from ScadaLink:Node:NodeName. - INodeIdentityProvider exposes the trimmed name (null if unconfigured) so downstream audit writers can stamp the new SourceNode column. --- .../Services/INodeIdentityProvider.cs | 22 ++++++++ src/ScadaLink.Host/NodeIdentityProvider.cs | 20 ++++++++ src/ScadaLink.Host/NodeOptions.cs | 8 +++ src/ScadaLink.Host/ScadaLink.Host.csproj | 4 ++ src/ScadaLink.Host/SiteServiceRegistration.cs | 6 +++ .../NodeIdentityProviderTests.cs | 50 +++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs create mode 100644 src/ScadaLink.Host/NodeIdentityProvider.cs create mode 100644 tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs diff --git a/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs b/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs new file mode 100644 index 0000000..f48fe25 --- /dev/null +++ b/src/ScadaLink.Commons/Interfaces/Services/INodeIdentityProvider.cs @@ -0,0 +1,22 @@ +namespace ScadaLink.Commons.Interfaces.Services; + +/// +/// Surfaces the local node's semantic role-within-cluster name so downstream +/// audit writers can stamp it on the SourceNode column. +/// +/// +/// Conventional values follow the pattern node-a/node-b on site +/// nodes and central-a/central-b on central nodes. The value is +/// a free-form operator-supplied label — there is no enforced format. When the +/// configuration value is missing, empty, or whitespace, implementations +/// return null so audit writers can persist NULL rather than an empty +/// string. +/// +public interface INodeIdentityProvider +{ + /// + /// The configured semantic node name, trimmed of surrounding whitespace. + /// null when unconfigured. + /// + string? NodeName { get; } +} diff --git a/src/ScadaLink.Host/NodeIdentityProvider.cs b/src/ScadaLink.Host/NodeIdentityProvider.cs new file mode 100644 index 0000000..6ea5fd9 --- /dev/null +++ b/src/ScadaLink.Host/NodeIdentityProvider.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.Host; + +/// +/// Binds to . +/// Empty or whitespace values are normalised to null; otherwise the value +/// is returned trimmed. +/// +internal sealed class NodeIdentityProvider : INodeIdentityProvider +{ + public string? NodeName { get; } + + public NodeIdentityProvider(IOptions nodeOptions) + { + var configured = nodeOptions.Value.NodeName; + NodeName = string.IsNullOrWhiteSpace(configured) ? null : configured.Trim(); + } +} diff --git a/src/ScadaLink.Host/NodeOptions.cs b/src/ScadaLink.Host/NodeOptions.cs index 3bc85fb..4088cb5 100644 --- a/src/ScadaLink.Host/NodeOptions.cs +++ b/src/ScadaLink.Host/NodeOptions.cs @@ -4,6 +4,14 @@ public class NodeOptions { public string Role { get; set; } = string.Empty; public string NodeHostname { get; set; } = string.Empty; + + /// + /// Operator-configured semantic node name used to stamp the SourceNode + /// column on audit rows. Conventional values are node-a/node-b + /// on site nodes and central-a/central-b on central nodes, + /// but the value is a free-form label — no validation is enforced. + /// + public string NodeName { get; set; } = string.Empty; public string? SiteId { get; set; } public int RemotingPort { get; set; } = 8081; public int GrpcPort { get; set; } = 8083; diff --git a/src/ScadaLink.Host/ScadaLink.Host.csproj b/src/ScadaLink.Host/ScadaLink.Host.csproj index 0f027e6..8bb5a4b 100644 --- a/src/ScadaLink.Host/ScadaLink.Host.csproj +++ b/src/ScadaLink.Host/ScadaLink.Host.csproj @@ -7,6 +7,10 @@ true + + + + diff --git a/src/ScadaLink.Host/SiteServiceRegistration.cs b/src/ScadaLink.Host/SiteServiceRegistration.cs index 0e59f9b..97ce314 100644 --- a/src/ScadaLink.Host/SiteServiceRegistration.cs +++ b/src/ScadaLink.Host/SiteServiceRegistration.cs @@ -1,6 +1,7 @@ using ScadaLink.AuditLog; using ScadaLink.ClusterInfrastructure; using ScadaLink.Communication; +using ScadaLink.Commons.Interfaces.Services; using ScadaLink.DataConnectionLayer; using ScadaLink.ExternalSystemGateway; using ScadaLink.HealthMonitoring; @@ -96,5 +97,10 @@ public static class SiteServiceRegistration services.Configure(config.GetSection("ScadaLink:HealthMonitoring")); services.Configure(config.GetSection("ScadaLink:Notification")); services.Configure(config.GetSection("ScadaLink:Logging")); + + // Audit Log (#23) — exposes ScadaLink:Node:NodeName to downstream audit + // writers so they can stamp the SourceNode column. Registered here in + // shared bootstrap because every node (central + site) needs it. + services.AddSingleton(); } } diff --git a/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs b/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs new file mode 100644 index 0000000..c7322a2 --- /dev/null +++ b/tests/ScadaLink.Host.Tests/NodeIdentityProviderTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Services; + +namespace ScadaLink.Host.Tests; + +/// +/// Tests for NodeIdentityProvider — surfaces the operator-configured semantic +/// node name (e.g. node-a / node-b / central-a / central-b) used by downstream +/// audit writers to stamp the SourceNode column. +/// +public class NodeIdentityProviderTests +{ + private static INodeIdentityProvider BuildProvider(string nodeName) + { + var options = Options.Create(new NodeOptions { NodeName = nodeName }); + return new NodeIdentityProvider(options); + } + + [Fact] + public void NodeIdentityProvider_returns_configured_NodeName() + { + var provider = BuildProvider("central-a"); + + Assert.Equal("central-a", provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_returns_null_when_NodeName_unset() + { + var provider = BuildProvider(string.Empty); + + Assert.Null(provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_returns_null_when_NodeName_whitespace() + { + var provider = BuildProvider(" "); + + Assert.Null(provider.NodeName); + } + + [Fact] + public void NodeIdentityProvider_trims_whitespace() + { + var provider = BuildProvider(" node-a "); + + Assert.Equal("node-a", provider.NodeName); + } +}