Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 |
@@ -147,3 +147,8 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
|
# Documentation review artifacts (CommentChecker output)
|
||||||
|
*-docs-issues.md
|
||||||
|
*-docs-fixed.md
|
||||||
|
*-docs-final.md
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||||
|
|
||||||
## Meter and OpenTelemetry Compatibility
|
## Meter and OpenTelemetry Compatibility
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
|
|||||||
```csharp
|
```csharp
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "ZB.MOM.WW.MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
public GatewayMetrics()
|
public GatewayMetrics()
|
||||||
{
|
{
|
||||||
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
|||||||
|
|
||||||
### Histograms
|
### Histograms
|
||||||
|
|
||||||
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
|
Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
```
|
```
|
||||||
|
|
||||||
| Instrument | Tags | What it measures |
|
| Instrument | Tags | What it measures |
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
|
||||||
|
Credentials are NOT committed: they are provided per-developer at the user level. -->
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="dohertj2-gitea">
|
||||||
|
<package pattern="ZB.MOM.WW.Health" />
|
||||||
|
<package pattern="ZB.MOM.WW.Health.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Configuration" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
+7
-7
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to bind gateway options from.</param>
|
||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
public static IServiceCollection AddGatewayConfiguration(
|
||||||
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
.AddOptions<GatewayOptions>()
|
configuration, GatewayOptions.SectionName);
|
||||||
.BindConfiguration(GatewayOptions.SectionName)
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
|
||||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
|
||||||
{
|
{
|
||||||
private const int MinimumMaxMessageBytes = 1024;
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
@@ -11,33 +11,26 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates gateway configuration options.
|
/// Validates gateway configuration options.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="name">Options name.</param>
|
/// <param name="builder">The accumulator to record failures on.</param>
|
||||||
/// <param name="options">Gateway options to validate.</param>
|
/// <param name="options">Gateway options to validate.</param>
|
||||||
/// <returns>Validation result.</returns>
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
|
||||||
{
|
{
|
||||||
List<string> failures = [];
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
|
ValidateLdap(options.Ldap, builder);
|
||||||
ValidateAuthentication(options.Authentication, failures);
|
ValidateWorker(options.Worker, builder);
|
||||||
ValidateLdap(options.Ldap, failures);
|
ValidateSessions(options.Sessions, builder);
|
||||||
ValidateWorker(options.Worker, failures);
|
ValidateEvents(options.Events, builder);
|
||||||
ValidateSessions(options.Sessions, failures);
|
ValidateDashboard(options.Dashboard, builder);
|
||||||
ValidateEvents(options.Events, failures);
|
ValidateProtocol(options.Protocol, builder);
|
||||||
ValidateDashboard(options.Dashboard, failures);
|
ValidateAlarms(options.Alarms, builder);
|
||||||
ValidateProtocol(options.Protocol, failures);
|
ValidateTls(options.Tls, builder);
|
||||||
ValidateAlarms(options.Alarms, failures);
|
|
||||||
ValidateTls(options.Tls, failures);
|
|
||||||
|
|
||||||
return failures.Count == 0
|
|
||||||
? ValidateOptionsResult.Success
|
|
||||||
: ValidateOptionsResult.Fail(failures);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!Enum.IsDefined(options.Mode))
|
if (!Enum.IsDefined(options.Mode))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,67 +39,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.PepperSecretName,
|
options.PepperSecretName,
|
||||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountDn,
|
options.ServiceAccountDn,
|
||||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountPassword,
|
options.ServiceAccountPassword,
|
||||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.UserNameAttribute,
|
options.UserNameAttribute,
|
||||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.DisplayNameAttribute,
|
options.DisplayNameAttribute,
|
||||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.GroupAttribute,
|
options.GroupAttribute,
|
||||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||||
|
|
||||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.ExecutablePath,
|
options.ExecutablePath,
|
||||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||||
@@ -114,94 +107,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.WorkingDirectory,
|
options.WorkingDirectory,
|
||||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupTimeoutSeconds,
|
options.StartupTimeoutSeconds,
|
||||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryAttempts,
|
options.StartupProbeRetryAttempts,
|
||||||
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryDelayMilliseconds,
|
options.StartupProbeRetryDelayMilliseconds,
|
||||||
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.ShutdownTimeoutSeconds,
|
options.ShutdownTimeoutSeconds,
|
||||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatIntervalSeconds,
|
options.HeartbeatIntervalSeconds,
|
||||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatGraceSeconds,
|
options.HeartbeatGraceSeconds,
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultCommandTimeoutSeconds,
|
options.DefaultCommandTimeoutSeconds,
|
||||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.MaxPendingCommandsPerSession,
|
options.MaxPendingCommandsPerSession,
|
||||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultLeaseSeconds,
|
options.DefaultLeaseSeconds,
|
||||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.LeaseSweepIntervalSeconds,
|
options.LeaseSweepIntervalSeconds,
|
||||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.AllowMultipleEventSubscribers)
|
if (options.AllowMultipleEventSubscribers)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||||
@@ -212,13 +205,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
||||||
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,18 +219,18 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.SnapshotIntervalMilliseconds,
|
options.SnapshotIntervalMilliseconds,
|
||||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentFaultLimit,
|
options.RecentFaultLimit,
|
||||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentSessionLimit,
|
options.RecentSessionLimit,
|
||||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
@@ -251,14 +244,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +259,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
private const int MinimumCertValidityYears = 1;
|
private const int MinimumCertValidityYears = 1;
|
||||||
private const int MaximumCertValidityYears = 100;
|
private const int MaximumCertValidityYears = 100;
|
||||||
|
|
||||||
private static void ValidateTls(TlsOptions options, List<string> failures)
|
private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,61 +271,52 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
foreach (string dns in options.AdditionalDnsNames)
|
foreach (string dns in options.AdditionalDnsNames)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dns))
|
if (string.IsNullOrWhiteSpace(dns))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value <= 0)
|
builder.RequireThat(value > 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
private static void AddIfNegative(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value < 0)
|
builder.RequireThat(value >= 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
@@ -345,15 +329,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (NotSupportedException)
|
catch (NotSupportedException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (PathTooLongException)
|
catch (PathTooLongException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway
|
||||||
|
/// authenticates every gRPC call against this store, so its reachability gates readiness.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthStoreHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly AuthSqliteConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
|
||||||
|
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||||
|
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1;";
|
||||||
|
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return HealthCheckResult.Healthy("Auth store is reachable.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapts the static <see cref="GatewayLogRedactor"/> to the shared <see cref="ILogRedactor"/> seam
|
||||||
|
/// so the telemetry RedactionEnricher masks API-key/credential material on every log event.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||||
|
{
|
||||||
|
private static readonly string[] IdentityKeys = ["ClientIdentity", "authorization", "Authorization"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Masks API-key/credential material in known identity-bearing log properties.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The log event property dictionary to redact in place.</param>
|
||||||
|
public void Redact(IDictionary<string, object?> properties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
foreach (var key in IdentityKeys)
|
||||||
|
{
|
||||||
|
if (properties.TryGetValue(key, out var value) && value is string s)
|
||||||
|
{
|
||||||
|
properties[key] = GatewayLogRedactor.RedactClientIdentity(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
|
using ZB.MOM.WW.Health;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -14,6 +15,8 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
using ZB.MOM.WW.Telemetry;
|
||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server;
|
namespace ZB.MOM.WW.MxGateway.Server;
|
||||||
|
|
||||||
@@ -60,11 +63,28 @@ public static class GatewayApplication
|
|||||||
|
|
||||||
ConfigureSelfSignedTls(builder);
|
ConfigureSelfSignedTls(builder);
|
||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
||||||
|
|
||||||
|
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
||||||
builder.Services.AddSqliteAuthStore();
|
builder.Services.AddSqliteAuthStore();
|
||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||||
|
"auth-store",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready });
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
|
builder.AddZbTelemetry(o =>
|
||||||
|
{
|
||||||
|
o.ServiceName = "mxgateway";
|
||||||
|
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
|
||||||
|
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||||
|
o.Exporter = exporter;
|
||||||
|
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(otlp))
|
||||||
|
o.OtlpEndpoint = otlp;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
||||||
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
@@ -169,13 +189,8 @@ public static class GatewayApplication
|
|||||||
{
|
{
|
||||||
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
||||||
|
|
||||||
endpoints.MapGet(
|
endpoints.MapZbHealth();
|
||||||
"/health/live",
|
endpoints.MapZbMetrics();
|
||||||
() => Results.Ok(new GatewayHealthReply(
|
|
||||||
Status: "Healthy",
|
|
||||||
DefaultBackend: GatewayContractInfo.DefaultBackendName,
|
|
||||||
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
|
||||||
.WithName("LiveHealth");
|
|
||||||
|
|
||||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
|||||||
|
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
private readonly Meter _meter;
|
private readonly Meter _meter;
|
||||||
@@ -68,9 +68,9 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
|
|
||||||
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
||||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||||
@@ -144,7 +144,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_workersRunning++;
|
_workersRunning++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds);
|
_workerStartupLatencyHistogram.Record(startupDuration.TotalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -208,7 +208,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
|
|
||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
_commandsSucceededCounter.Add(1, methodTag);
|
_commandsSucceededCounter.Add(1, methodTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,7 +228,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
KeyValuePair<string, object?> categoryTag = new("category", category);
|
KeyValuePair<string, object?> categoryTag = new("category", category);
|
||||||
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag, categoryTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -255,7 +255,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
public void RecordEventStreamSend(string family, TimeSpan duration)
|
public void RecordEventStreamSend(string family, TimeSpan duration)
|
||||||
{
|
{
|
||||||
_eventStreamSendLatencyHistogram.Record(
|
_eventStreamSendLatencyHistogram.Record(
|
||||||
duration.TotalMilliseconds,
|
duration.TotalSeconds,
|
||||||
new KeyValuePair<string, object?>("family", family));
|
new KeyValuePair<string, object?>("family", family));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||||
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": { "Microsoft.AspNetCore": "Warning" }
|
||||||
}
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Serilog": {
|
||||||
"LogLevel": {
|
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||||
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Override": { "Microsoft.AspNetCore": "Warning" }
|
||||||
}
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{ "Name": "File", "Args": { "path": "logs/mxgateway-.log", "rollingInterval": "Day" } }
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"MxGateway": {
|
"MxGateway": {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public sealed class GatewayOptionsTests
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
// design-default values; those defaults are validated separately in GatewayOptionsTests.
|
||||||
private static GatewayOptions ValidOptions() => new();
|
private static GatewayOptions ValidOptions() => new();
|
||||||
|
|
||||||
|
// Returns enabled LDAP options that pass all checks except Port.
|
||||||
|
// The class defaults already satisfy the blank-field checks; we only
|
||||||
|
// override Enabled (must be true to exercise the port check) and Port.
|
||||||
|
private static LdapOptions LdapOptionsWithPort(int port) => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
Port = port,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static GatewayOptions CloneWithLdap(GatewayOptions source, LdapOptions ldap)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
Authentication = source.Authentication,
|
||||||
|
Ldap = ldap,
|
||||||
|
Worker = source.Worker,
|
||||||
|
Sessions = source.Sessions,
|
||||||
|
Events = source.Events,
|
||||||
|
Dashboard = source.Dashboard,
|
||||||
|
Protocol = source.Protocol,
|
||||||
|
Alarms = source.Alarms,
|
||||||
|
Tls = source.Tls,
|
||||||
|
};
|
||||||
|
|
||||||
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
private static GatewayOptions CloneWithTls(GatewayOptions source, TlsOptions tls)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
@@ -65,4 +88,34 @@ public sealed class GatewayOptionsValidatorTests
|
|||||||
Assert.True(result.Failed);
|
Assert.True(result.Failed);
|
||||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Tls:SelfSignedCertPath must not be blank."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLdapPortIsZero()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(0));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 0)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Fails_WhenLdapPortExceedsMaximum()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(70000));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Failed);
|
||||||
|
Assert.Contains(
|
||||||
|
result.Failures!,
|
||||||
|
f => f.Contains("MxGateway:Ldap:Port must be between 1 and 65535 (was 70000)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_Succeeds_WhenLdapEnabledWithValidPort()
|
||||||
|
{
|
||||||
|
GatewayOptions options = CloneWithLdap(ValidOptions(), LdapOptionsWithPort(389));
|
||||||
|
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public sealed class AuthStoreHealthCheckTests
|
||||||
|
{
|
||||||
|
private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath)
|
||||||
|
{
|
||||||
|
// GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both
|
||||||
|
// init-only, so populate them through object initializers.
|
||||||
|
var options = new GatewayOptions
|
||||||
|
{
|
||||||
|
Authentication = new AuthenticationOptions { SqlitePath = sqlitePath },
|
||||||
|
};
|
||||||
|
return new AuthSqliteConnectionFactory(Options.Create(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Healthy_WhenStoreReachable()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var check = new AuthStoreHealthCheck(FactoryFor(path));
|
||||||
|
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||||
|
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||||
|
}
|
||||||
|
finally { if (File.Exists(path)) File.Delete(path); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unhealthy_WhenPathUnusable()
|
||||||
|
{
|
||||||
|
// A regular file used as a parent directory forces the open to fail.
|
||||||
|
var bogus = Path.Combine(Path.GetTempPath(), $"authcheck-{Guid.NewGuid():N}");
|
||||||
|
await File.WriteAllTextAsync(bogus, "x");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var check = new AuthStoreHealthCheck(FactoryFor(Path.Combine(bogus, "store.db")));
|
||||||
|
var result = await check.CheckHealthAsync(new HealthCheckContext());
|
||||||
|
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||||
|
}
|
||||||
|
finally { if (File.Exists(bogus)) File.Delete(bogus); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
public class GatewayLogRedactorSeamTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Redact_MasksApiKeyInClientIdentity()
|
||||||
|
{
|
||||||
|
var redactor = new GatewayLogRedactorSeam();
|
||||||
|
var props = new Dictionary<string, object?> { ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret" };
|
||||||
|
redactor.Redact(props);
|
||||||
|
Assert.Equal("Bearer mxgw_operator01_[redacted]", props["ClientIdentity"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.MxGateway.Server;
|
using ZB.MOM.WW.MxGateway.Server;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -11,19 +14,31 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
|||||||
|
|
||||||
public sealed class GatewayApplicationTests
|
public sealed class GatewayApplicationTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
|
/// <summary>Verifies that Build maps the canonical three health tiers.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_MapsLiveHealthEndpoint()
|
public async Task Build_MapsCanonicalHealthEndpoints()
|
||||||
{
|
{
|
||||||
await using WebApplication app = GatewayApplication.Build([]);
|
await using WebApplication app = GatewayApplication.Build([]);
|
||||||
|
|
||||||
RouteEndpoint endpoint = Assert.Single(
|
var paths = ((IEndpointRouteBuilder)app).DataSources
|
||||||
((IEndpointRouteBuilder)app).DataSources
|
.SelectMany(dataSource => dataSource.Endpoints)
|
||||||
.SelectMany(dataSource => dataSource.Endpoints)
|
.OfType<RouteEndpoint>()
|
||||||
.OfType<RouteEndpoint>(),
|
.Select(e => e.RoutePattern.RawText)
|
||||||
candidate => candidate.RoutePattern.RawText == "/health/live");
|
.ToHashSet();
|
||||||
|
|
||||||
Assert.Equal("LiveHealth", endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName);
|
Assert.Contains("/health/ready", paths);
|
||||||
|
Assert.Contains("/health/active", paths);
|
||||||
|
Assert.Contains("/healthz", paths);
|
||||||
|
Assert.DoesNotContain("/health/live", paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that Build registers Serilog as the host logging provider.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Build_UsesSerilogLoggerProvider()
|
||||||
|
{
|
||||||
|
using var app = GatewayApplication.Build([]);
|
||||||
|
var factory = app.Services.GetRequiredService<ILoggerFactory>();
|
||||||
|
Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
||||||
@@ -37,6 +52,28 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.NotNull(metrics);
|
Assert.NotNull(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Build_MapsMetricsEndpoint()
|
||||||
|
{
|
||||||
|
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||||
|
// started-host test must avoid a fixed port to prevent a bind collision.
|
||||||
|
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
|
||||||
|
await app.StartAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
|
||||||
|
|
||||||
|
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await app.StopAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
services.AddSqliteAuthStore();
|
services.AddSqliteAuthStore();
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
|
|||||||
|
|
||||||
ServiceCollection services = new();
|
ServiceCollection services = new();
|
||||||
services.AddSingleton<IConfiguration>(configuration);
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
services.AddGatewayConfiguration();
|
services.AddGatewayConfiguration(configuration);
|
||||||
services.AddSqliteAuthStore();
|
services.AddSqliteAuthStore();
|
||||||
|
|
||||||
return services.BuildServiceProvider(validateScopes: true);
|
return services.BuildServiceProvider(validateScopes: true);
|
||||||
|
|||||||
Reference in New Issue
Block a user