feat(historian-gateway): reshape ServerHistorianOptions to gateway form (Endpoint/ApiKey/Tls)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
@@ -5,13 +5,17 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
|
/// Binds the <c>ServerHistorian</c> configuration section that gates the server-side
|
||||||
/// HistoryRead backend. When <see cref="Enabled"/> is <c>true</c>, <c>AddServerHistorian</c>
|
/// HistoryRead backend. When <see cref="Enabled"/> is <c>true</c>, <c>AddServerHistorian</c>
|
||||||
/// registers a read-only <c>WonderwareHistorianClient</c> (supplied by the Host) as the
|
/// registers a read-only <c>HistorianGateway</c>-backed <c>IHistorianDataSource</c> (supplied by
|
||||||
/// <c>IHistorianDataSource</c> in place of the <c>NullHistorianDataSource</c> default;
|
/// the Host) in place of the <c>NullHistorianDataSource</c> default; otherwise the Null default
|
||||||
/// otherwise the Null default survives and HistoryRead returns <c>GoodNoData</c>-empty.
|
/// survives and HistoryRead returns <c>GoodNoData</c>-empty.
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is the READ path only — there are no DatabasePath / drain / capacity / retention
|
/// The read client talks gRPC to the <c>ZB.MOM.WW.HistorianGateway</c> sidecar
|
||||||
/// knobs (those belong to the write-side <c>AlarmHistorian</c> store-and-forward sink). The
|
/// (<c>historian_gateway.v1</c>) over <see cref="Endpoint"/>, authenticating with the
|
||||||
/// client's own <c>CallTimeout</c> bounds each read; the node manager adds no extra timeout.
|
/// peppered-HMAC <see cref="ApiKey"/> (<c>histgw_<id>_<secret></c>) in the
|
||||||
|
/// <c>Authorization: Bearer</c> header. This is the READ path only — there are no
|
||||||
|
/// DatabasePath / drain / capacity / retention knobs (those belong to the write-side
|
||||||
|
/// <c>AlarmHistorian</c> store-and-forward sink). The client's own <see cref="CallTimeout"/>
|
||||||
|
/// bounds each read; the node manager adds no extra timeout.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ServerHistorianOptions
|
public sealed class ServerHistorianOptions
|
||||||
@@ -20,26 +24,38 @@ public sealed class ServerHistorianOptions
|
|||||||
public const string SectionName = "ServerHistorian";
|
public const string SectionName = "ServerHistorian";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// When <c>true</c>, the Wonderware read client is registered as the
|
/// When <c>true</c>, the HistorianGateway read client is registered as the
|
||||||
/// <c>IHistorianDataSource</c>; when <c>false</c> (the default) the no-op
|
/// <c>IHistorianDataSource</c>; when <c>false</c> (the default) the no-op
|
||||||
/// <c>NullHistorianDataSource</c> stays in place and HistoryRead returns empty.
|
/// <c>NullHistorianDataSource</c> stays in place and HistoryRead returns empty.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; init; }
|
public bool Enabled { get; init; }
|
||||||
|
|
||||||
/// <summary>TCP hostname or IP address the Wonderware historian sidecar listens on.</summary>
|
/// <summary>
|
||||||
public string Host { get; init; } = "localhost";
|
/// Absolute gateway endpoint URI the read client dials (e.g. <c>https://host:5222</c>).
|
||||||
|
/// The scheme selects the transport: <c>https://</c> = TLS, <c>http://</c> = h2c plaintext.
|
||||||
|
/// Required when <see cref="Enabled"/> is <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Endpoint { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>TCP port the Wonderware historian sidecar listens on.</summary>
|
/// <summary>
|
||||||
public int Port { get; init; } = 32569;
|
/// The peppered-HMAC API key (<c>histgw_<id>_<secret></c>) the gateway validates
|
||||||
|
/// in the <c>Authorization: Bearer</c> header. Supply via the environment variable
|
||||||
|
/// <c>ServerHistorian__ApiKey</c> — never commit it to config. Required when
|
||||||
|
/// <see cref="Enabled"/> is <c>true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string ApiKey { get; init; } = "";
|
||||||
|
|
||||||
/// <summary>When <c>true</c>, the client connects over TLS.</summary>
|
/// <summary>When <c>true</c> (the default), the client connects over TLS; must match the <see cref="Endpoint"/> scheme.</summary>
|
||||||
public bool UseTls { get; init; }
|
public bool UseTls { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Expected TLS server certificate thumbprint (hex, no spaces). Null or empty disables pinning.</summary>
|
/// <summary>When <c>true</c>, the client accepts a self-signed / untrusted server certificate (dev / on-prem only).</summary>
|
||||||
public string? ServerCertThumbprint { get; init; }
|
public bool AllowUntrustedServerCertificate { get; init; }
|
||||||
|
|
||||||
/// <summary>Per-process shared secret the sidecar verifies in the Hello frame.</summary>
|
/// <summary>Path to a PEM CA certificate that pins the gateway's TLS trust chain. Null or empty uses the OS trust store.</summary>
|
||||||
public string SharedSecret { get; init; } = "";
|
public string? CaCertificatePath { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Per-call deadline applied to each unary gateway read. Defaults to 30 seconds.</summary>
|
||||||
|
public TimeSpan CallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The upper bound on the bounded over-fetch the HistoryRead-Raw paging uses to page WITHIN an
|
/// The upper bound on the bounded over-fetch the HistoryRead-Raw paging uses to page WITHIN an
|
||||||
@@ -54,15 +70,15 @@ public sealed class ServerHistorianOptions
|
|||||||
|
|
||||||
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
/// <summary>Returns operator-facing misconfiguration warnings for an <c>Enabled</c> historian
|
||||||
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
/// (empty when disabled or correctly configured). Pure — the registration logs each entry.</summary>
|
||||||
/// <returns>Zero or more human-readable warning messages.</returns>
|
/// <returns>Zero or more human-readable warning messages (never carrying secret values).</returns>
|
||||||
public IReadOnlyList<string> Validate()
|
public IReadOnlyList<string> Validate()
|
||||||
{
|
{
|
||||||
var warnings = new List<string>();
|
var warnings = new List<string>();
|
||||||
if (!Enabled) return warnings;
|
if (!Enabled) return warnings;
|
||||||
if (string.IsNullOrWhiteSpace(SharedSecret))
|
if (string.IsNullOrWhiteSpace(Endpoint))
|
||||||
warnings.Add("ServerHistorian:SharedSecret is empty while the historian is enabled — the Wonderware sidecar Hello frame will carry an empty secret.");
|
warnings.Add("ServerHistorian:Endpoint is empty while the historian is enabled — the read client has no gateway address to dial.");
|
||||||
if (Port <= 0)
|
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||||
warnings.Add($"ServerHistorian:Port is {Port} — must be > 0; the read client cannot dial the sidecar.");
|
warnings.Add("ServerHistorian:ApiKey is empty while the historian is enabled — the gateway gRPC surface will reject unauthenticated calls.");
|
||||||
// MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above:
|
// MaxTieClusterOverfetch is intentionally checked AFTER the Enabled early-return above:
|
||||||
// the over-fetch code path only runs when a real IHistorianDataSource is wired in,
|
// the over-fetch code path only runs when a real IHistorianDataSource is wired in,
|
||||||
// so a zero/negative value is harmless (and noise-free) when the historian is disabled.
|
// so a zero/negative value is harmless (and noise-free) when the historian is disabled.
|
||||||
|
|||||||
@@ -104,9 +104,8 @@ public sealed class AddServerHistorianTests
|
|||||||
var config = ConfigFrom(new Dictionary<string, string?>
|
var config = ConfigFrom(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["ServerHistorian:Enabled"] = "true",
|
["ServerHistorian:Enabled"] = "true",
|
||||||
["ServerHistorian:Host"] = "historian.example.com",
|
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
|
||||||
["ServerHistorian:Port"] = "32569",
|
["ServerHistorian:ApiKey"] = "histgw_x_y",
|
||||||
["ServerHistorian:SharedSecret"] = "s",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource());
|
services.AddServerHistorian(config, (_, _) => new FakeHistorianDataSource());
|
||||||
@@ -126,11 +125,10 @@ public sealed class AddServerHistorianTests
|
|||||||
var config = ConfigFrom(new Dictionary<string, string?>
|
var config = ConfigFrom(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["ServerHistorian:Enabled"] = "true",
|
["ServerHistorian:Enabled"] = "true",
|
||||||
["ServerHistorian:Host"] = "historian.example.com",
|
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
|
||||||
["ServerHistorian:Port"] = "12345",
|
["ServerHistorian:ApiKey"] = "histgw_x_y",
|
||||||
["ServerHistorian:UseTls"] = "true",
|
["ServerHistorian:UseTls"] = "true",
|
||||||
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
|
["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem",
|
||||||
["ServerHistorian:SharedSecret"] = "s",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddServerHistorian(config, (opts, _) =>
|
services.AddServerHistorian(config, (opts, _) =>
|
||||||
@@ -143,55 +141,29 @@ public sealed class AddServerHistorianTests
|
|||||||
_ = provider.GetRequiredService<IHistorianDataSource>();
|
_ = provider.GetRequiredService<IHistorianDataSource>();
|
||||||
|
|
||||||
seen.ShouldNotBeNull();
|
seen.ShouldNotBeNull();
|
||||||
seen.Host.ShouldBe("historian.example.com");
|
seen.Endpoint.ShouldBe("https://historian.example.com:5222");
|
||||||
seen.Port.ShouldBe(12345);
|
seen.ApiKey.ShouldBe("histgw_x_y");
|
||||||
seen.UseTls.ShouldBeTrue();
|
seen.UseTls.ShouldBeTrue();
|
||||||
seen.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
|
seen.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Validate_warns_on_empty_shared_secret_when_enabled()
|
public void Section_binds_endpoint_apikey_tls_fields()
|
||||||
{
|
|
||||||
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "", Port = 32569 };
|
|
||||||
opts.Validate().ShouldContain(w => w.Contains("SharedSecret"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_warns_on_non_positive_port_when_enabled()
|
|
||||||
{
|
|
||||||
var opts = new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 0 };
|
|
||||||
opts.Validate().ShouldContain(w => w.Contains("Port"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_is_silent_when_correctly_configured()
|
|
||||||
{
|
|
||||||
new ServerHistorianOptions { Enabled = true, SharedSecret = "s", Port = 32569 }.Validate().ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Validate_is_silent_when_disabled()
|
|
||||||
{
|
|
||||||
new ServerHistorianOptions { Enabled = false, SharedSecret = "", Port = 0 }.Validate().ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Section_binds_host_port_tls_fields()
|
|
||||||
{
|
{
|
||||||
var config = ConfigFrom(new Dictionary<string, string?>
|
var config = ConfigFrom(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["ServerHistorian:Host"] = "historian.example.com",
|
["ServerHistorian:Endpoint"] = "https://historian.example.com:5222",
|
||||||
["ServerHistorian:Port"] = "12345",
|
["ServerHistorian:ApiKey"] = "histgw_x_y",
|
||||||
["ServerHistorian:UseTls"] = "true",
|
["ServerHistorian:UseTls"] = "true",
|
||||||
["ServerHistorian:ServerCertThumbprint"] = "AABBCCDDEEFF",
|
["ServerHistorian:CaCertificatePath"] = "/etc/ssl/gateway-ca.pem",
|
||||||
});
|
});
|
||||||
|
|
||||||
var opts = config.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
var opts = config.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>();
|
||||||
|
|
||||||
opts.ShouldNotBeNull();
|
opts.ShouldNotBeNull();
|
||||||
opts.Host.ShouldBe("historian.example.com");
|
opts.Endpoint.ShouldBe("https://historian.example.com:5222");
|
||||||
opts.Port.ShouldBe(12345);
|
opts.ApiKey.ShouldBe("histgw_x_y");
|
||||||
opts.UseTls.ShouldBeTrue();
|
opts.UseTls.ShouldBeTrue();
|
||||||
opts.ServerCertThumbprint.ShouldBe("AABBCCDDEEFF");
|
opts.CaCertificatePath.ShouldBe("/etc/ssl/gateway-ca.pem");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Covers the gateway-shaped <see cref="ServerHistorianOptions.Validate"/> warnings: a disabled or
|
||||||
|
/// correctly-configured historian is silent; an enabled one with a blank <c>Endpoint</c> or blank
|
||||||
|
/// <c>ApiKey</c> surfaces an operator-facing warning (carrying no secret value text).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerHistorianOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_yields_no_warnings()
|
||||||
|
=> Assert.Empty(new ServerHistorianOptions { Enabled = false }.Validate());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enabled_without_endpoint_warns()
|
||||||
|
{
|
||||||
|
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "", ApiKey = "histgw_x_y" }.Validate();
|
||||||
|
Assert.Contains(w, m => m.Contains("Endpoint"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Enabled_without_apikey_warns()
|
||||||
|
{
|
||||||
|
var w = new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "" }.Validate();
|
||||||
|
Assert.Contains(w, m => m.Contains("ApiKey"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Valid_config_is_clean()
|
||||||
|
=> Assert.Empty(new ServerHistorianOptions { Enabled = true, Endpoint = "https://h:5222", ApiKey = "histgw_x_y" }.Validate());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user