Fix all baseline code-review findings across the six shared libraries
Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).
- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
XML docs on the public parameter surface.
Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Fans an event out to several writers. Best-effort: a failing writer does not stop the others.</summary>
|
||||
/// <remarks>A failing writer's exception is swallowed so the fan-out drains and the caller is never
|
||||
/// aborted — but <see cref="OperationCanceledException"/> is re-thrown so cancellation is honored.</remarks>
|
||||
/// <remarks>Every inner-writer failure is swallowed — including <see cref="OperationCanceledException"/>
|
||||
/// — so the fan-out drains and the caller is never aborted, honoring the <see cref="IAuditWriter"/>
|
||||
/// "must not throw to the caller" contract even when a request-scoped cancellation token is passed.</remarks>
|
||||
public sealed class CompositeAuditWriter : IAuditWriter
|
||||
{
|
||||
private readonly IReadOnlyList<IAuditWriter> _inner;
|
||||
@@ -21,8 +22,7 @@ public sealed class CompositeAuditWriter : IAuditWriter
|
||||
foreach (var writer in _inner)
|
||||
{
|
||||
try { await writer.WriteAsync(evt, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { throw; } // honor cancellation; do not swallow
|
||||
catch { /* best-effort seam: a failing writer must not stop the others or the caller */ }
|
||||
catch { /* best-effort seam: a failing writer (incl. cancellation) must not stop the others or the caller */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Redactor that caps oversized <see cref="AuditEvent.DetailsJson"/> and <see cref="AuditEvent.Target"/>.
|
||||
/// Never throws — over-redacts (drops DetailsJson) on internal failure. The secret-field policy
|
||||
/// (which fields are sensitive) stays per-project; compose this with a project redactor as needed.
|
||||
/// Never throws — over-redacts (drops both DetailsJson and Target) on internal failure. The
|
||||
/// secret-field policy (which fields are sensitive) stays per-project; compose this with a project
|
||||
/// redactor as needed.
|
||||
/// </summary>
|
||||
public sealed class TruncatingAuditRedactor : IAuditRedactor
|
||||
{
|
||||
@@ -26,13 +27,15 @@ public sealed class TruncatingAuditRedactor : IAuditRedactor
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Hard contract: never throw. Over-redact on internal failure.
|
||||
return rawEvent with { DetailsJson = null };
|
||||
// Hard contract: never throw, and over-redact to a STRICTLY safer event on internal
|
||||
// failure — scrub every field this redactor owns (both DetailsJson and Target).
|
||||
return rawEvent with { DetailsJson = null, Target = null };
|
||||
}
|
||||
}
|
||||
|
||||
private string? Truncate(string? value, int max)
|
||||
{
|
||||
if (max < 0) max = 0; // clamp nonsensical negative caps so a config bug fails safe, not throws
|
||||
if (value is null || value.Length <= max) return value;
|
||||
var marker = _options.TruncationMarker;
|
||||
if (marker.Length >= max) return marker[..max];
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace ZB.MOM.WW.Audit;
|
||||
|
||||
/// <summary>Caps for <see cref="TruncatingAuditRedactor"/>.</summary>
|
||||
public sealed class TruncatingAuditRedactorOptions
|
||||
/// <summary>Immutable caps for <see cref="TruncatingAuditRedactor"/>.</summary>
|
||||
public sealed record TruncatingAuditRedactorOptions
|
||||
{
|
||||
/// <summary>Max length of <see cref="AuditEvent.DetailsJson"/> before truncation. Default 4096.</summary>
|
||||
public int MaxDetailsJsonLength { get; set; } = 4096;
|
||||
public int MaxDetailsJsonLength { get; init; } = 4096;
|
||||
/// <summary>Max length of <see cref="AuditEvent.Target"/> before truncation. Default 512.</summary>
|
||||
public int MaxTargetLength { get; set; } = 512;
|
||||
public int MaxTargetLength { get; init; } = 512;
|
||||
/// <summary>Marker appended to a truncated value. Default "…[truncated]".</summary>
|
||||
public string TruncationMarker { get; set; } = "…[truncated]";
|
||||
public string TruncationMarker { get; init; } = "…[truncated]";
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<!-- Emit and pack XML docs so consumers get IntelliSense/tooltip documentation. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -38,11 +38,32 @@ public class CompositeAuditWriterTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_is_propagated_not_swallowed()
|
||||
public async Task Cancellation_does_not_surface_to_the_caller()
|
||||
{
|
||||
// OperationCanceledException is re-thrown (unlike ordinary writer failures, which are swallowed).
|
||||
// Per the IAuditWriter hard contract ("must not throw to the caller"), an
|
||||
// OperationCanceledException from an inner writer is swallowed like any other failure —
|
||||
// it must NOT abort the user-facing action that produced the event.
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter[] { new CancellingWriter(), after });
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() => sut.WriteAsync(Evt()));
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count); // drain continues past the cancelled writer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Empty_writer_list_is_a_no_op()
|
||||
{
|
||||
var sut = new CompositeAuditWriter(Array.Empty<IAuditWriter>());
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_writer_entry_is_swallowed_and_does_not_stop_the_others()
|
||||
{
|
||||
// A null inner writer faults the await; the best-effort seam swallows it (like any
|
||||
// other writer failure) and continues draining the remaining writers.
|
||||
var after = new RecordingWriter();
|
||||
var sut = new CompositeAuditWriter(new IAuditWriter?[] { null, after }!);
|
||||
await sut.WriteAsync(Evt()); // must not throw
|
||||
Assert.Equal(1, after.Count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,30 @@ public class TruncatingAuditRedactorTests
|
||||
var result = r.Apply(Evt(new string('x', 20)));
|
||||
Assert.Equal(3, result.DetailsJson!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_max_is_treated_as_zero_and_does_not_throw()
|
||||
{
|
||||
// A negative cap is nonsensical misconfiguration. Truncate must clamp to 0 rather than
|
||||
// throw, capping the value to the empty string (plus marker handling).
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = -5, MaxTargetLength = -1, TruncationMarker = "" };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var result = r.Apply(Evt(new string('x', 20), target: new string('y', 20)));
|
||||
Assert.Equal(string.Empty, result.DetailsJson);
|
||||
Assert.Equal(string.Empty, result.Target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Over_redact_fallback_scrubs_both_details_and_target_without_throwing()
|
||||
{
|
||||
// Drive the REAL TruncatingAuditRedactor.Apply into its catch branch via a reachable
|
||||
// misconfiguration (a null TruncationMarker faults inside Truncate). The over-redact
|
||||
// fallback must be strictly safer: BOTH DetailsJson AND Target scrubbed to null, no throw.
|
||||
var opts = new TruncatingAuditRedactorOptions { MaxDetailsJsonLength = 5, TruncationMarker = null! };
|
||||
var r = new TruncatingAuditRedactor(opts);
|
||||
var raw = Evt(new string('x', 50), target: "sensitive target");
|
||||
var result = r.Apply(raw);
|
||||
Assert.Null(result.DetailsJson);
|
||||
Assert.Null(result.Target);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
|
||||
|---|---|---|
|
||||
| `ZB.MOM.WW.Auth.Abstractions` | Auth contracts, canonical role constants, and shared types (`LdapOptions`, `LdapAuthResult`, `ILdapAuthService`, `IApiKeyStore`). No runtime dependencies beyond the BCL. | — |
|
||||
| `ZB.MOM.WW.Auth.Ldap` | LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. | `Abstractions`, `Novell.Directory.Ldap.NETStandard` |
|
||||
| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with pepper-based PBKDF2 hashing, rotation, and audit log. Includes a `MigrationHostedService` that runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
|
||||
| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core DI helpers (`AddZbAuth`), cookie defaults, claim-type constants, and `LdapOptionsValidator` registration. Wires together Ldap + ApiKeys + cookie middleware. | `Abstractions`, `Ldap`, `ApiKeys`, `Microsoft.AspNetCore.App` |
|
||||
| `ZB.MOM.WW.Auth.ApiKeys` | SQLite-backed API-key store with **pepper-keyed HMAC-SHA256** secret hashing, rotation, and audit log. DI wiring is `AddZbApiKeyAuth`; an opt-in `MigrationHostedService` runs schema migrations on startup. | `Abstractions`, `Microsoft.Data.Sqlite` |
|
||||
| `ZB.MOM.WW.Auth.AspNetCore` | ASP.NET Core wiring for the **LDAP** provider only: `AddZbLdapAuth` (binds + start-time-validates `LdapOptions`, registers `ILdapAuthService`), plus `ZbCookieDefaults.Apply` (hardened cookie helper the consumer calls itself) and `ZbClaimTypes` constants. It does **not** wire API keys or cookie middleware — API-key DI is `AddZbApiKeyAuth` in the `ApiKeys` package. | `Abstractions`, `Ldap`, `Microsoft.AspNetCore.App` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.Security;
|
||||
|
||||
namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
public enum LdapTransport { Ldaps, StartTls, None }
|
||||
@@ -16,6 +18,16 @@ public sealed record LdapOptions
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
public int ConnectionTimeoutMs { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Optional hook to harden (or, in dev, relax) TLS server-certificate validation for the
|
||||
/// <see cref="LdapTransport.Ldaps"/> / <see cref="LdapTransport.StartTls"/> transports. When
|
||||
/// <see langword="null"/> (the default) the LDAP client validates the server certificate against
|
||||
/// the OS trust store — it does <em>not</em> blind-accept. Supply a callback to pin a CA, validate
|
||||
/// the SAN against <see cref="Server"/>, or otherwise tighten validation. This is a code-only seam
|
||||
/// (not bound from configuration) and takes precedence over <see cref="AllowInsecure"/>.
|
||||
/// </summary>
|
||||
public RemoteCertificateValidationCallback? ServerCertificateValidationCallback { get; init; }
|
||||
}
|
||||
|
||||
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }
|
||||
|
||||
@@ -101,7 +101,10 @@ public sealed class ApiKeyAdminCommands
|
||||
|
||||
var record = new ApiKeyRecord(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"{_options.TokenPrefix}_{keyId}",
|
||||
// KeyPrefix is the bare token prefix (e.g. "mxgw"), NOT prefix_keyId — the key id is
|
||||
// already its own column. Embedding it here produced a self-referential value that
|
||||
// confused admin tooling and disagreed with the read/test paths (see Auth-005).
|
||||
KeyPrefix: _options.TokenPrefix,
|
||||
SecretHash: secretHash,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes,
|
||||
|
||||
@@ -62,8 +62,24 @@ public sealed class ApiKeyVerifier(
|
||||
return Fail(ApiKeyFailure.SecretMismatch);
|
||||
}
|
||||
|
||||
// 6. Record successful use, then return the identity (no secret/hash/pepper included).
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
// 6. The authentication decision is already made (line 60). Recording last-used is
|
||||
// best-effort bookkeeping: a transient storage hiccup (SQLITE_BUSY past the busy-timeout,
|
||||
// disk full, DB locked by a migration) must NOT turn an otherwise-valid credential into a
|
||||
// failed auth. Swallow any non-cancellation failure so the only exception path remains
|
||||
// cancellation, as the class contract promises. Cancellation is honoured (re-thrown).
|
||||
try
|
||||
{
|
||||
await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort: the last-used write failed, but the credential is valid. Fail open on the
|
||||
// bookkeeping (not the auth decision) rather than denying a legitimate caller.
|
||||
}
|
||||
|
||||
return new ApiKeyVerification(
|
||||
Succeeded: true,
|
||||
|
||||
@@ -20,7 +20,12 @@ public static class ScopeSerializer
|
||||
|
||||
/// <summary>Deserializes scopes from a JSON array string.</summary>
|
||||
/// <param name="value">The JSON string to deserialize; may be null or empty.</param>
|
||||
/// <returns>An ordinal-compared set of scopes; empty when the input is null/blank.</returns>
|
||||
/// <returns>
|
||||
/// An ordinal-compared set of scopes; empty when the input is null/blank. A malformed or
|
||||
/// non-array column (operator tampering, a partial write, a format change, or a buggy writer)
|
||||
/// fails closed to an EMPTY set rather than throwing, so a single poisoned row degrades to a
|
||||
/// zero-scope identity on the auth path instead of an unhandled <see cref="JsonException"/>.
|
||||
/// </returns>
|
||||
public static IReadOnlySet<string> Deserialize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
@@ -28,7 +33,18 @@ public static class ScopeSerializer
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
string[]? scopes;
|
||||
try
|
||||
{
|
||||
scopes = JsonSerializer.Deserialize<string[]>(value);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fail closed: a corrupt scopes column yields no scopes rather than an exception on the
|
||||
// verification hot path. The verifier's "only exception path is cancellation" contract
|
||||
// is preserved, and a key with an unreadable scope set is left with zero authority.
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,14 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.Configure<LdapOptions>(config.GetSection(sectionPath));
|
||||
// Bind via the options builder and opt into start-time validation. An IValidateOptions<T>
|
||||
// otherwise only runs when the options are first materialized (IOptions<T>.Value) — which
|
||||
// here is the first login (ILdapAuthService factory below), not boot. ValidateOnStart hooks
|
||||
// the host's start-time options validation so a misconfigured directory (e.g. insecure
|
||||
// transport without AllowInsecure) fails fast at startup rather than on first login.
|
||||
services.AddOptions<LdapOptions>()
|
||||
.Bind(config.GetSection(sectionPath))
|
||||
.ValidateOnStart();
|
||||
|
||||
// Fail fast at startup on a misconfigured directory rather than on first login.
|
||||
services.AddSingleton<IValidateOptions<LdapOptions>, LdapOptionsValidator>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,8 +16,29 @@ internal sealed record LdapSearchEntry(
|
||||
/// </summary>
|
||||
internal interface ILdapConnection : IDisposable
|
||||
{
|
||||
/// <summary>Opens (and optionally upgrades to TLS) a connection to the given host.</summary>
|
||||
void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs);
|
||||
/// <summary>
|
||||
/// Opens (and optionally upgrades to TLS) a connection to the given host.
|
||||
/// </summary>
|
||||
/// <param name="host">The LDAP server hostname or IP.</param>
|
||||
/// <param name="port">The LDAP server port.</param>
|
||||
/// <param name="transport">The transport security mode.</param>
|
||||
/// <param name="allowInsecure">
|
||||
/// When <see langword="true"/> AND no <paramref name="serverCertificateValidationCallback"/> is
|
||||
/// supplied, TLS server-certificate validation is bypassed (dev/test only). Ignored when a
|
||||
/// validation callback is supplied (the callback wins) or for plaintext transport.
|
||||
/// </param>
|
||||
/// <param name="timeoutMs">The connection/operation timeout in milliseconds.</param>
|
||||
/// <param name="serverCertificateValidationCallback">
|
||||
/// Optional TLS server-certificate validation callback. When <see langword="null"/>, the OS trust
|
||||
/// store is used (the client does not blind-accept).
|
||||
/// </param>
|
||||
void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback);
|
||||
|
||||
/// <summary>Binds with the supplied DN and password. Throws <c>LdapException</c> on bad credentials.</summary>
|
||||
void Bind(string dn, string password);
|
||||
|
||||
@@ -2,19 +2,67 @@ namespace ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
using Novell.Directory.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
// Disambiguate: Novell also declares a RemoteCertificateValidationCallback delegate; the seam and
|
||||
// LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback both use the BCL one.
|
||||
using RemoteCertificateValidationCallback = System.Net.Security.RemoteCertificateValidationCallback;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="ILdapConnection"/> backed by <c>Novell.Directory.Ldap.LdapConnection</c>.
|
||||
/// Mirrors the connection/search idioms from ZB.MOM.WW.ScadaBridge.Security.LdapAuthService.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// TLS server-certificate validation: by default the underlying
|
||||
/// <c>Novell.Directory.Ldap.NETStandard</c> client validates the server certificate against the OS
|
||||
/// trust store (it does NOT blind-accept). A caller-supplied
|
||||
/// <c>RemoteCertificateValidationCallback</c> overrides that default (CA pinning / SAN checks); when
|
||||
/// none is supplied and <c>allowInsecure</c> is set, validation is bypassed for dev/test only.
|
||||
/// </remarks>
|
||||
internal sealed class NovellLdapConnection : ILdapConnection
|
||||
{
|
||||
private readonly LdapConnection _conn = new();
|
||||
private readonly LdapConnection _conn;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
/// <summary>
|
||||
/// Builds the connection, wiring a TLS server-certificate validation policy: a supplied
|
||||
/// <paramref name="serverCertificateValidationCallback"/> wins; otherwise <paramref name="allowInsecure"/>
|
||||
/// bypasses validation (dev/test only); otherwise the OS-trust-store default applies.
|
||||
/// </summary>
|
||||
public NovellLdapConnection(
|
||||
bool allowInsecure = false,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
if (serverCertificateValidationCallback is not null)
|
||||
{
|
||||
var options = new LdapConnectionOptions()
|
||||
.ConfigureRemoteCertificateValidationCallback(serverCertificateValidationCallback);
|
||||
_conn = new LdapConnection(options);
|
||||
}
|
||||
else if (allowInsecure)
|
||||
{
|
||||
// Dev/test only: accept any server certificate. Reachable solely when an operator has set
|
||||
// AllowInsecure (rejected for plaintext-without-AllowInsecure by LdapOptionsValidator).
|
||||
var options = new LdapConnectionOptions()
|
||||
.ConfigureRemoteCertificateValidationCallback((_, _, _, _) => true);
|
||||
_conn = new LdapConnection(options);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: validate against the OS trust store (no blind-accept).
|
||||
_conn = new LdapConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback)
|
||||
{
|
||||
// The TLS-validation policy (allowInsecure / callback) is wired at construction time on the
|
||||
// LdapConnectionOptions; the per-call arguments here are accepted for seam symmetry.
|
||||
ApplyTimeout(timeoutMs);
|
||||
|
||||
// LDAPS: TLS is negotiated at the TCP-connection level.
|
||||
@@ -98,8 +146,16 @@ internal sealed class NovellLdapConnection : ILdapConnection
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Factory that produces fresh <see cref="NovellLdapConnection"/> instances.</summary>
|
||||
internal sealed class NovellLdapConnectionFactory : ILdapConnectionFactory
|
||||
/// <summary>
|
||||
/// Factory that produces fresh <see cref="NovellLdapConnection"/> instances, carrying the TLS
|
||||
/// server-certificate validation policy (a supplied callback, or an <c>allowInsecure</c> bypass) so
|
||||
/// it is wired onto each connection at construction time.
|
||||
/// </summary>
|
||||
internal sealed class NovellLdapConnectionFactory(
|
||||
bool allowInsecure = false,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
: ILdapConnectionFactory
|
||||
{
|
||||
public ILdapConnection Create() => new NovellLdapConnection();
|
||||
public ILdapConnection Create() =>
|
||||
new NovellLdapConnection(allowInsecure, serverCertificateValidationCallback);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,14 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: binds against a live directory via the real
|
||||
/// Novell-backed connection factory.
|
||||
/// Novell-backed connection factory. The TLS server-certificate validation policy
|
||||
/// (<see cref="LdapOptions.ServerCertificateValidationCallback"/> or the
|
||||
/// <see cref="LdapOptions.AllowInsecure"/> bypass) is carried into the factory so each
|
||||
/// connection is built with it.
|
||||
/// </summary>
|
||||
public LdapAuthService(LdapOptions options)
|
||||
: this(options, new NovellLdapConnectionFactory())
|
||||
: this(options, new NovellLdapConnectionFactory(
|
||||
options.AllowInsecure, options.ServerCertificateValidationCallback))
|
||||
{
|
||||
}
|
||||
|
||||
@@ -92,7 +96,13 @@ public sealed class LdapAuthService : ILdapAuthService
|
||||
// Abstractions change could add DirectoryUnavailable to disambiguate.
|
||||
try
|
||||
{
|
||||
conn.Connect(_options.Server, _options.Port, _options.Transport, _options.AllowInsecure, _options.ConnectionTimeoutMs);
|
||||
conn.Connect(
|
||||
_options.Server,
|
||||
_options.Port,
|
||||
_options.Transport,
|
||||
_options.AllowInsecure,
|
||||
_options.ConnectionTimeoutMs,
|
||||
_options.ServerCertificateValidationCallback);
|
||||
}
|
||||
catch (LdapException)
|
||||
{
|
||||
|
||||
@@ -87,6 +87,33 @@ public sealed class ApiKeyAdminCommandsTests : IAsyncLifetime
|
||||
Assert.Single(recent, e => e.EventType == "create-key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId()
|
||||
{
|
||||
// Auth-005: KeyPrefix is the bare token prefix ("mxgw"), NOT "mxgw_key-1". The key id is
|
||||
// already its own column; embedding it produced a self-referential value that disagreed with
|
||||
// the read/test paths and confused admin tooling.
|
||||
ApiKeyAdminCommands commands = BuildCommands();
|
||||
await commands.InitDbAsync(null, CancellationToken.None);
|
||||
|
||||
await commands.CreateKeyAsync(
|
||||
"key-1",
|
||||
"Service A",
|
||||
new HashSet<string>(["read"], StringComparer.Ordinal),
|
||||
constraintsJson: null,
|
||||
remoteAddress: null,
|
||||
CancellationToken.None);
|
||||
|
||||
ApiKeyRecord? found = await _read.FindByKeyIdAsync("key-1", CancellationToken.None);
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal("mxgw", found!.KeyPrefix);
|
||||
|
||||
// The same bare prefix is surfaced by the admin list projection.
|
||||
IReadOnlyList<ApiKeyListItem> listed = await commands.ListKeysAsync(CancellationToken.None);
|
||||
ApiKeyListItem item = Assert.Single(listed, k => k.KeyId == "key-1");
|
||||
Assert.Equal("mxgw", item.KeyPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKey_PepperUnavailable_ReturnsNoTokenAndAppendsNoAudit()
|
||||
{
|
||||
|
||||
@@ -212,6 +212,51 @@ public class ApiKeyVerifierTests
|
||||
Assert.DoesNotContain(Convert.ToBase64String(hash), identityText, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// --- Auth-002: a failed best-effort MarkUsedAsync must NOT fail a valid key ---
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds()
|
||||
{
|
||||
// MarkUsedAsync is best-effort "last used" bookkeeping. A transient storage failure
|
||||
// (SQLITE_BUSY, disk full, locked DB) must not turn an otherwise-valid credential into a
|
||||
// failed auth: the decision is already made before the usage write. The verifier's contract
|
||||
// is "the only exception path is cancellation", so a non-cancellation MarkUsedAsync failure
|
||||
// is swallowed and the result is still Succeeded == true.
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash),
|
||||
MarkUsedException = new InvalidOperationException("SQLITE_BUSY"),
|
||||
};
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
ApiKeyVerification result =
|
||||
await verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Null(result.Failure);
|
||||
Assert.NotNull(result.Identity);
|
||||
Assert.Equal(KeyId, result.Identity!.KeyId);
|
||||
Assert.True(store.MarkUsedCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyAsync_MarkUsedThrowsOperationCanceled_Propagates()
|
||||
{
|
||||
// The ONLY exception path is cancellation: an OperationCanceledException from the usage
|
||||
// write (e.g. the request was cancelled mid-write) is honoured and re-thrown, not swallowed.
|
||||
byte[] hash = ApiKeySecretHasher.Hash(Secret, Pepper);
|
||||
var store = new FakeApiKeyStore
|
||||
{
|
||||
Record = BuildRecord(hash),
|
||||
MarkUsedException = new OperationCanceledException(),
|
||||
};
|
||||
var verifier = BuildVerifier(store, new FakePepperProvider(Pepper));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
() => verifier.VerifyAsync(Header(KeyId, Secret), CancellationToken.None));
|
||||
}
|
||||
|
||||
// --- Cancellation ---
|
||||
|
||||
[Fact]
|
||||
@@ -253,6 +298,9 @@ public class ApiKeyVerifierTests
|
||||
public string? MarkUsedKeyId { get; private set; }
|
||||
public DateTimeOffset? MarkUsedWhenUtc { get; private set; }
|
||||
|
||||
/// <summary>When set, <see cref="MarkUsedAsync"/> throws this exception (after recording the call).</summary>
|
||||
public Exception? MarkUsedException { get; set; }
|
||||
|
||||
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
|
||||
{
|
||||
FindByKeyIdCalled = true;
|
||||
@@ -267,6 +315,11 @@ public class ApiKeyVerifierTests
|
||||
MarkUsedCalled = true;
|
||||
MarkUsedKeyId = keyId;
|
||||
MarkUsedWhenUtc = whenUtc;
|
||||
if (MarkUsedException is not null)
|
||||
{
|
||||
return Task.FromException(MarkUsedException);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,36 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
||||
Assert.Empty(ScopeSerializer.Deserialize(""));
|
||||
}
|
||||
|
||||
// --- Auth-003: corrupt scopes JSON must fail closed (empty set), never throw JsonException ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{")]
|
||||
[InlineData("{\"a\":1}")] // valid JSON, but an object, not a string[]
|
||||
[InlineData("42")] // valid JSON, but a number
|
||||
[InlineData("[\"read\",")] // truncated/partial write
|
||||
public void ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow(string value)
|
||||
{
|
||||
// A poisoned scopes column (tampering, partial write, format change, buggy writer) must
|
||||
// degrade to a zero-scope set rather than throwing on the verification hot path.
|
||||
IReadOnlySet<string> scopes = ScopeSerializer.Deserialize(value);
|
||||
Assert.Empty(scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow()
|
||||
{
|
||||
// Insert a row whose scopes column holds malformed (non-array) JSON, then read it through
|
||||
// the store. The store must NOT propagate a JsonException out of FindByKeyIdAsync (which the
|
||||
// verifier relies on for its "only exception path is cancellation" contract).
|
||||
await InsertWithRawScopesAsync("key-corrupt", scopesJson: "{ this is not valid json");
|
||||
|
||||
ApiKeyRecord? found = await _store.FindByKeyIdAsync("key-corrupt", CancellationToken.None);
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Empty(found!.Scopes);
|
||||
}
|
||||
|
||||
private static ApiKeyRecord SampleRecord(string keyId) => new(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: "mxgw_ab12",
|
||||
@@ -213,6 +243,33 @@ public sealed class SqliteApiKeyStoreTests : IAsyncLifetime
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task InsertWithRawScopesAsync(string keyId, string scopesJson)
|
||||
{
|
||||
// Writes the scopes column verbatim (NOT via ScopeSerializer.Serialize) so a malformed
|
||||
// value can be persisted to simulate tampering / a partial or buggy write.
|
||||
await using SqliteConnection connection =
|
||||
await _factory.OpenConnectionAsync(CancellationToken.None);
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO api_keys (
|
||||
key_id, key_prefix, secret_hash, display_name, scopes,
|
||||
constraints, created_utc, last_used_utc, revoked_utc)
|
||||
VALUES (
|
||||
$key_id, $key_prefix, $secret_hash, $display_name, $scopes,
|
||||
$constraints, $created_utc, $last_used_utc, $revoked_utc);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$key_id", keyId);
|
||||
command.Parameters.AddWithValue("$key_prefix", "mxgw");
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3 };
|
||||
command.Parameters.AddWithValue("$display_name", "Corrupt Key");
|
||||
command.Parameters.AddWithValue("$scopes", scopesJson);
|
||||
command.Parameters.AddWithValue("$constraints", DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UnixEpoch.ToString("O"));
|
||||
command.Parameters.AddWithValue("$last_used_utc", DBNull.Value);
|
||||
command.Parameters.AddWithValue("$revoked_utc", DBNull.Value);
|
||||
await command.ExecuteNonQueryAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
+49
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
@@ -85,4 +86,52 @@ public class ServiceCollectionExtensionsTests
|
||||
|
||||
Assert.Contains(validators, v => v is LdapOptionsValidator);
|
||||
}
|
||||
|
||||
// --- Auth-001: ValidateOnStart must run options validation at host startup, not first login ---
|
||||
|
||||
private static IConfiguration BuildInsecureConfiguration() =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{LdapSection}:Server"] = LdapServer,
|
||||
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
|
||||
[$"{LdapSection}:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
|
||||
// Plaintext transport without AllowInsecure: the validator must reject this.
|
||||
[$"{LdapSection}:Transport"] = nameof(LdapTransport.None),
|
||||
[$"{LdapSection}:AllowInsecure"] = "false",
|
||||
})
|
||||
.Build();
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbLdapAuth_StartingHost_FailsForInsecureConfig()
|
||||
{
|
||||
// The misconfiguration must surface at host start, not deferred until the first login
|
||||
// (i.e. the first ILdapAuthService resolution). ValidateOnStart wires the host's
|
||||
// start-time options validation, so StartAsync must throw OptionsValidationException.
|
||||
IConfiguration config = BuildInsecureConfiguration();
|
||||
|
||||
using IHost host = new HostBuilder()
|
||||
.ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
|
||||
.Build();
|
||||
|
||||
OptionsValidationException ex =
|
||||
await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
|
||||
|
||||
Assert.Contains(nameof(LdapOptions.Transport), string.Join(" ", ex.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddZbLdapAuth_StartingHost_SucceedsForSecureConfig()
|
||||
{
|
||||
// A valid (secure) config must start cleanly — proving ValidateOnStart does not reject
|
||||
// well-formed options.
|
||||
IConfiguration config = BuildConfiguration();
|
||||
|
||||
using IHost host = new HostBuilder()
|
||||
.ConfigureServices(services => services.AddZbLdapAuth(config, LdapSection))
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
await host.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap.Internal;
|
||||
|
||||
@@ -19,6 +20,10 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
// ---- observation -----
|
||||
|
||||
public (string Host, int Port, LdapTransport Transport, bool AllowInsecure, int TimeoutMs)? ConnectArgs { get; private set; }
|
||||
|
||||
/// <summary>The server-certificate validation callback passed to the most recent <see cref="Connect"/> call.</summary>
|
||||
public RemoteCertificateValidationCallback? ConnectCertCallback { get; private set; }
|
||||
|
||||
public List<string> BoundDns { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -107,9 +112,16 @@ internal sealed class FakeLdapConnection : ILdapConnection
|
||||
|
||||
// ---- ILdapConnection -----
|
||||
|
||||
public void Connect(string host, int port, LdapTransport transport, bool allowInsecure, int timeoutMs)
|
||||
public void Connect(
|
||||
string host,
|
||||
int port,
|
||||
LdapTransport transport,
|
||||
bool allowInsecure,
|
||||
int timeoutMs,
|
||||
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
|
||||
{
|
||||
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
|
||||
ConnectCertCallback = serverCertificateValidationCallback;
|
||||
if (_throwOnConnect)
|
||||
throw new Novell.Directory.Ldap.LdapException(
|
||||
"Directory unreachable", Novell.Directory.Ldap.LdapException.ConnectError, host);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Ldap;
|
||||
|
||||
@@ -80,6 +81,56 @@ public class LdapAuthServiceTests
|
||||
Assert.Equal(LdapAuthFailure.Disabled, (await svc.AuthenticateAsync("a", "b", default)).Failure);
|
||||
}
|
||||
|
||||
// --- Auth-006: TLS validation seam — allowInsecure is honoured and a cert-validation
|
||||
// callback is threaded into the connection rather than being silently ignored. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ReceivesAllowInsecureFlag_FromOptions()
|
||||
{
|
||||
// The allowInsecure flag must reach the connection (it used to be an unused parameter).
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(
|
||||
Opts() with { AllowInsecure = true }, new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.NotNull(fake.ConnectArgs);
|
||||
Assert.True(fake.ConnectArgs!.Value.AllowInsecure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_ReceivesConfiguredCertValidationCallback()
|
||||
{
|
||||
// A consumer-supplied RemoteCertificateValidationCallback must be passed through to the
|
||||
// connection so production callers can pin a CA / validate the SAN — the seam no longer
|
||||
// discards it.
|
||||
RemoteCertificateValidationCallback callback = (_, _, _, _) => true;
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(
|
||||
Opts() with { ServerCertificateValidationCallback = callback },
|
||||
new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.Same(callback, fake.ConnectCertCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_NoCertCallbackConfigured_PassesNull()
|
||||
{
|
||||
// Default: no callback configured -> null reaches the connection, which means the
|
||||
// production adapter falls back to OS-trust-store validation (documented behaviour).
|
||||
var fake = new FakeLdapConnection().WithUserEntry(
|
||||
"cn=alice,dc=x", memberOf: new[] { "cn=Engineers,ou=g,dc=x" });
|
||||
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
||||
|
||||
await svc.AuthenticateAsync("alice", "pw", default);
|
||||
|
||||
Assert.Null(fake.ConnectCertCallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreservesEscapedCommaInGroupName_OnRfc4514Dn()
|
||||
{
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,11 +18,14 @@ internal static class Checks
|
||||
|
||||
/// <summary>
|
||||
/// Validates a raw string as a TCP port (parse + range), returning <c>null</c> when valid.
|
||||
/// Centralizes the port wording for callers that hold the raw config value.
|
||||
/// Centralizes the port wording for callers that hold the raw config value. Parsing is strict
|
||||
/// and culture-invariant (<see cref="NumberStyles.None"/>): a leading sign or surrounding
|
||||
/// whitespace is rejected. Both the parse-failure and range-failure messages quote the offending
|
||||
/// raw value so they read consistently.
|
||||
/// </summary>
|
||||
internal static string? PortValue(string? raw, string field) =>
|
||||
int.TryParse(raw, out var port)
|
||||
? Port(port, field)
|
||||
int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var port) && port is >= 1 and <= 65535
|
||||
? null
|
||||
: $"{field} must be between 1 and 65535 (was '{raw ?? "null"}')";
|
||||
|
||||
/// <summary>
|
||||
@@ -33,7 +38,7 @@ internal static class Checks
|
||||
var idx = value.LastIndexOf(':');
|
||||
if (idx <= 0 || idx == value.Length - 1
|
||||
|| value.AsSpan(0, idx).Contains(':')
|
||||
|| !int.TryParse(value[(idx + 1)..], out var port)
|
||||
|| !int.TryParse(value[(idx + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|
||||
|| port is < 1 or > 65535)
|
||||
return $"{field} must be 'host:port' with port 1-65535 (was '{value}')";
|
||||
return null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration;
|
||||
@@ -33,7 +34,7 @@ public static class ServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sectionPath);
|
||||
|
||||
services.AddSingleton<IValidateOptions<TOptions>, TValidator>();
|
||||
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<TOptions>, TValidator>());
|
||||
return services.AddOptions<TOptions>()
|
||||
.Bind(configuration.GetSection(sectionPath))
|
||||
.ValidateOnStart();
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
<PackageTags>configuration;options;validation;ivalidateoptions;validateonstart;startup;scada;wonderware;zb-mom-ww</PackageTags>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</PackageProjectUrl>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/zb-mom-ww-configuration</RepositoryUrl>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||
|
||||
+20
@@ -44,4 +44,24 @@ public sealed class AddValidatedOptionsTests
|
||||
Assert.Equal("central", opts.Name);
|
||||
await host.StopAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calling_twice_registers_validator_once()
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { ["Node:Port"] = "0", ["Node:Name"] = "" })
|
||||
.Build();
|
||||
var services = new ServiceCollection();
|
||||
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
|
||||
services.AddValidatedOptions<NodeOptions, NodeValidator>(config, "Node");
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var validators = provider.GetServices<IValidateOptions<NodeOptions>>().ToArray();
|
||||
Assert.Single(validators);
|
||||
|
||||
// Resolving the options surfaces each accumulated failure exactly once, not doubled.
|
||||
var ex = Assert.Throws<OptionsValidationException>(
|
||||
() => provider.GetRequiredService<IOptions<NodeOptions>>().Value);
|
||||
Assert.Equal(2, ex.Failures.Count());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.Configuration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the exact failure-message wording produced by the shared <c>Checks</c> seam through its
|
||||
/// public front-ends (<see cref="ConfigPreflight"/> for raw port values, <see cref="ValidationBuilder"/>
|
||||
/// for host:port endpoints). Covers Configuration-002 (consistent quoting) and Configuration-003
|
||||
/// (strict, culture-invariant port parsing).
|
||||
/// </summary>
|
||||
public sealed class ChecksWordingTests
|
||||
{
|
||||
private static IConfiguration Config(string key, string? value) =>
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?> { [key] = value })
|
||||
.Build();
|
||||
|
||||
private static string PortFailure(string? rawValue)
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", rawValue)).RequirePort("X:Port");
|
||||
return Assert.Single(pf.Failures);
|
||||
}
|
||||
|
||||
// Configuration-002: range failure and parse failure must quote the offending value the same way.
|
||||
|
||||
[Fact]
|
||||
public void PortValue_range_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was '0')", PortFailure("0"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_high_range_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was '70000')", PortFailure("70000"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_parse_failure_quotes_the_value()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was 'notaport')", PortFailure("notaport"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_null_failure_renders_null()
|
||||
{
|
||||
Assert.Equal("X:Port must be between 1 and 65535 (was 'null')", PortFailure(null));
|
||||
}
|
||||
|
||||
// Configuration-003: strict, culture-invariant parsing rejects sign and surrounding whitespace.
|
||||
|
||||
[Theory]
|
||||
[InlineData("+5000")]
|
||||
[InlineData(" 5000")]
|
||||
[InlineData("5000 ")]
|
||||
[InlineData(" 5000 ")]
|
||||
[InlineData("-1")]
|
||||
public void PortValue_rejects_loose_inputs(string raw)
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", raw)).RequirePort("X:Port");
|
||||
Assert.False(pf.IsValid);
|
||||
Assert.Equal($"X:Port must be between 1 and 65535 (was '{raw}')", Assert.Single(pf.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PortValue_accepts_plain_in_range_port()
|
||||
{
|
||||
var pf = ConfigPreflight.For(Config("X:Port", "5000")).RequirePort("X:Port");
|
||||
Assert.True(pf.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("host:+5000")]
|
||||
[InlineData("host: 5000")]
|
||||
[InlineData("host:5000 ")]
|
||||
public void HostPort_rejects_loose_port_inputs(string value)
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort(value, "X:Endpoint");
|
||||
Assert.False(b.IsValid);
|
||||
Assert.Equal($"X:Endpoint must be 'host:port' with port 1-65535 (was '{value}')", Assert.Single(b.Failures));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostPort_accepts_plain_endpoint()
|
||||
{
|
||||
var b = new ValidationBuilder();
|
||||
b.HostPort("host:5000", "X:Endpoint");
|
||||
Assert.True(b.IsValid);
|
||||
}
|
||||
}
|
||||
+2
@@ -1,6 +1,8 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<!-- Test project does not ship; no XML docs required (overrides Directory.Build.props). -->
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Version>0.1.0</Version>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
<!-- Emit XML docs so the public API summaries ship inside the packed nupkgs (IntelliSense for
|
||||
consumers). CS1591 (missing doc on a public member) is suppressed so undocumented test /
|
||||
non-packed members do not break the build; the src public surface is fully documented. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -103,27 +103,41 @@ public sealed class ActiveNodeHealthCheck : IHealthCheck
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
var cluster = Cluster.Get(system);
|
||||
var self = cluster.SelfMember;
|
||||
var selfUp = self.Status == MemberStatus.Up;
|
||||
|
||||
MemberStatus selfStatus;
|
||||
bool selfUp;
|
||||
bool hasRole;
|
||||
bool isLeader;
|
||||
if (_role is null)
|
||||
try
|
||||
{
|
||||
hasRole = false;
|
||||
var leader = cluster.State.Leader;
|
||||
isLeader = leader is not null && leader == self.Address;
|
||||
// Reading cluster membership can throw while the ActorSystem exists but the cluster has
|
||||
// not finished initialising (e.g. Akka.Cluster not yet configured →
|
||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded rather
|
||||
// than letting the exception escape (which the host would record as Unhealthy).
|
||||
var cluster = Cluster.Get(system);
|
||||
var self = cluster.SelfMember;
|
||||
selfStatus = self.Status;
|
||||
selfUp = selfStatus == MemberStatus.Up;
|
||||
|
||||
if (_role is null)
|
||||
{
|
||||
hasRole = false;
|
||||
var leader = cluster.State.Leader;
|
||||
isLeader = leader is not null && leader == self.Address;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasRole = self.HasRole(_role);
|
||||
var roleLeader = cluster.State.RoleLeader(_role);
|
||||
isLeader = roleLeader is not null && roleLeader == self.Address;
|
||||
}
|
||||
}
|
||||
else
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
hasRole = self.HasRole(_role);
|
||||
var roleLeader = cluster.State.RoleLeader(_role);
|
||||
isLeader = roleLeader is not null && roleLeader == self.Address;
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var health = ActiveNodeDecision.Evaluate(selfUp, isLeader, hasRole, _role);
|
||||
var description = DescribeResult(health, self.Status, selfUp, isLeader);
|
||||
var description = DescribeResult(health, selfStatus, selfUp, isLeader);
|
||||
var result = health switch
|
||||
{
|
||||
HealthStatus.Healthy => HealthCheckResult.Healthy(description),
|
||||
|
||||
@@ -8,7 +8,8 @@ namespace ZB.MOM.WW.Health.Akka;
|
||||
/// <summary>
|
||||
/// Health check that maps the local node's Akka cluster membership status to a
|
||||
/// <see cref="HealthStatus"/> through a configurable <see cref="AkkaClusterStatusPolicy"/>.
|
||||
/// Register to the <see cref="ZbHealthTags.Ready"/> tag (recommended <c>[ready, active]</c>).
|
||||
/// Register to the <see cref="ZbHealthTags.Ready"/> tag only — cluster membership is a readiness
|
||||
/// concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for the leader / active-node probe.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ActorSystem"/> is resolved lazily from the service provider. If it is not yet
|
||||
@@ -42,7 +43,21 @@ public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
if (system is null)
|
||||
return Task.FromResult(HealthCheckResult.Degraded("ActorSystem not yet available."));
|
||||
|
||||
var status = Cluster.Get(system).SelfMember.Status;
|
||||
MemberStatus status;
|
||||
try
|
||||
{
|
||||
// Cluster.Get(system).SelfMember can throw while the ActorSystem exists but the cluster
|
||||
// has not finished initialising (e.g. Akka.Cluster not yet configured →
|
||||
// ConfigurationException). The spec's startup-safety rule maps this to Degraded, not an
|
||||
// escaping exception (which the host would record as Unhealthy and pull the node from
|
||||
// rotation).
|
||||
status = Cluster.Get(system).SelfMember.Status;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Degraded("Akka cluster state not yet accessible.", ex));
|
||||
}
|
||||
|
||||
var health = _policy.Evaluate(status);
|
||||
var description = $"Akka cluster member status: {status}";
|
||||
var result = health switch
|
||||
|
||||
@@ -13,14 +13,15 @@ namespace ZB.MOM.WW.Health;
|
||||
/// The probe is injectable via <see cref="GrpcDependencyOptions.Probe"/>; the default drives the
|
||||
/// channel to a connected state with <see cref="GrpcChannel.ConnectAsync"/>. The result is
|
||||
/// <see cref="HealthStatus.Healthy"/> when the probe returns <c>true</c>, and
|
||||
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws an
|
||||
/// <see cref="RpcException"/>, or times out / is cancelled within
|
||||
/// <see cref="GrpcDependencyOptions.Timeout"/>.
|
||||
/// <see cref="HealthStatus.Unhealthy"/> when it returns <c>false</c>, throws any exception
|
||||
/// (<see cref="RpcException"/> or otherwise), or times out within
|
||||
/// <see cref="GrpcDependencyOptions.Timeout"/>. External cancellation of the supplied
|
||||
/// <see cref="CancellationToken"/> propagates as an <see cref="OperationCanceledException"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Recommended registration tags: <see cref="ZbHealthTags.Ready"/> and
|
||||
/// <see cref="ZbHealthTags.Active"/> — a missing downstream gRPC dependency makes the node both
|
||||
/// not-ready and not-able-to-act. The registrant applies the tags.
|
||||
/// Recommended registration tag: <see cref="ZbHealthTags.Ready"/> only — downstream gRPC
|
||||
/// reachability is a readiness concern; the <see cref="ZbHealthTags.Active"/> tier is reserved for
|
||||
/// the leader / active-node probe. The registrant applies the tag.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class GrpcDependencyHealthCheck : IHealthCheck
|
||||
@@ -74,6 +75,15 @@ public sealed class GrpcDependencyHealthCheck : IHealthCheck
|
||||
{
|
||||
return HealthCheckResult.Unhealthy($"{name} probe timed out after {_options.Timeout}.", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Catch-all to match the sibling DatabaseHealthCheck: any other probe error
|
||||
// (e.g. InvalidOperationException / HttpRequestException / SocketException from the
|
||||
// transport, or anything a custom probe throws) maps to Unhealthy rather than escaping
|
||||
// the IHealthCheck boundary. The OCE/Rpc external-cancellation handlers above run first,
|
||||
// so caller cancellation still propagates.
|
||||
return HealthCheckResult.Unhealthy($"{name} probe failed: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -28,9 +28,9 @@ public static class ZbHealthEndpointExtensions
|
||||
/// emits a minimal <c>200 OK</c> body.
|
||||
/// </remarks>
|
||||
/// <returns>
|
||||
/// The <see cref="IEndpointConventionBuilder"/> for the readiness (<c>/health/ready</c>) endpoint.
|
||||
/// A single tier is returned (rather than a composite) to keep the API simple; conventions
|
||||
/// applied to the result affect only the readiness endpoint.
|
||||
/// A composite <see cref="IEndpointConventionBuilder"/> that fans every chained convention out to
|
||||
/// <em>all three</em> health endpoints (readiness, active, and liveness). For example,
|
||||
/// <c>endpoints.MapZbHealth().RequireHost("…")</c> gates all three endpoints, as a caller expects.
|
||||
/// </returns>
|
||||
public static IEndpointConventionBuilder MapZbHealth(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
@@ -47,7 +47,7 @@ public static class ZbHealthEndpointExtensions
|
||||
ResponseWriter = responseWriter,
|
||||
}).AllowAnonymous();
|
||||
|
||||
endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
|
||||
var active = endpoints.MapHealthChecks(options.ActivePath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = static c => c.Tags.Contains(ZbHealthTags.Active),
|
||||
ResponseWriter = responseWriter,
|
||||
@@ -56,12 +56,38 @@ public static class ZbHealthEndpointExtensions
|
||||
// Liveness: run no checks. The endpoint returns 200 as long as the process can respond.
|
||||
// No JSON writer — the empty report would carry no useful data, so the framework default
|
||||
// (a minimal plain-text body) is sufficient.
|
||||
endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
|
||||
var live = endpoints.MapHealthChecks(options.LivePath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = static _ => false,
|
||||
}).AllowAnonymous();
|
||||
|
||||
return ready;
|
||||
return new CompositeEndpointConventionBuilder(ready, active, live);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IEndpointConventionBuilder"/> that forwards each convention to several
|
||||
/// underlying builders, so conventions chained onto the result of
|
||||
/// <see cref="MapZbHealth(IEndpointRouteBuilder, ZbHealthEndpointOptions?)"/> apply to all three
|
||||
/// health endpoints rather than just one.
|
||||
/// </summary>
|
||||
private sealed class CompositeEndpointConventionBuilder : IEndpointConventionBuilder
|
||||
{
|
||||
private readonly IEndpointConventionBuilder[] _builders;
|
||||
|
||||
public CompositeEndpointConventionBuilder(params IEndpointConventionBuilder[] builders) =>
|
||||
_builders = builders;
|
||||
|
||||
public void Add(Action<EndpointBuilder> convention)
|
||||
{
|
||||
foreach (var builder in _builders)
|
||||
builder.Add(convention);
|
||||
}
|
||||
|
||||
public void Finally(Action<EndpointBuilder> finalConvention)
|
||||
{
|
||||
foreach (var builder in _builders)
|
||||
builder.Finally(finalConvention);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -70,7 +96,10 @@ public static class ZbHealthEndpointExtensions
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder to map onto.</param>
|
||||
/// <param name="configure">Callback that mutates a fresh <see cref="ZbHealthEndpointOptions"/>.</param>
|
||||
/// <returns>The <see cref="IEndpointConventionBuilder"/> for the readiness endpoint.</returns>
|
||||
/// <returns>
|
||||
/// A composite <see cref="IEndpointConventionBuilder"/> that fans chained conventions out to all
|
||||
/// three health endpoints.
|
||||
/// </returns>
|
||||
public static IEndpointConventionBuilder MapZbHealth(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
Action<ZbHealthEndpointOptions> configure)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
@@ -21,15 +20,21 @@ namespace ZB.MOM.WW.Health;
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded → 200,
|
||||
/// Unhealthy → 503); this writer only renders the body and sets <c>Content-Type: application/json</c>.
|
||||
/// The <c>description</c> key is always present; when a check supplies no description it is emitted
|
||||
/// as JSON <c>null</c> (not omitted), matching the spec example and the <c>HealthChecks.UI.Client</c>
|
||||
/// shape. The HTTP status code is left to the ASP.NET Core health-checks middleware (Healthy/Degraded
|
||||
/// → 200, Unhealthy → 503); this writer only renders the body and sets
|
||||
/// <c>Content-Type: application/json</c>.
|
||||
/// </remarks>
|
||||
public static class ZbHealthWriter
|
||||
{
|
||||
// Null properties are emitted (not omitted) so a null `description` renders as
|
||||
// "description": null — matching the SPEC §3 example and the HealthChecks.UI.Client shape this
|
||||
// writer mirrors. Consumers can then read entries.<name>.description without handling a missing
|
||||
// property. (Do not set DefaultIgnoreCondition = WhenWritingNull here.)
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -86,6 +86,50 @@ public sealed class ActiveNodeDecisionTests
|
||||
Assert.False(gate.IsActiveNode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleLess_ClusterInaccessible_ReturnsDegraded()
|
||||
{
|
||||
// ActorSystem present but Akka.Cluster not configured → Cluster.Get throws. The check must
|
||||
// return Degraded (startup-safety rule), not let the exception escape (→ Unhealthy).
|
||||
using var system = ActorSystem.Create("plain-no-cluster-roleless");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddSingleton(system)
|
||||
.BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_RoleFiltered_ClusterInaccessible_ReturnsDegraded()
|
||||
{
|
||||
using var system = ActorSystem.Create("plain-no-cluster-rolefiltered");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddSingleton(system)
|
||||
.BuildServiceProvider();
|
||||
var check = new ActiveNodeHealthCheck(provider, "admin");
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("active-node", check, HealthStatus.Unhealthy, tags: null),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
@@ -70,6 +71,30 @@ public sealed class AkkaClusterStatusPolicyTests
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ActorSystemPresentButClusterInaccessible_ReturnsDegraded()
|
||||
{
|
||||
// A plain (non-clustered) ActorSystem exists in DI, but Akka.Cluster is not configured,
|
||||
// so Cluster.Get(system) throws a ConfigurationException — the startup race the spec calls
|
||||
// out. The check must return Degraded, not let the exception escape (→ Unhealthy via the host).
|
||||
using var system = ActorSystem.Create("plain-no-cluster");
|
||||
try
|
||||
{
|
||||
var provider = new ServiceCollection()
|
||||
.AddSingleton(system)
|
||||
.BuildServiceProvider();
|
||||
var check = new AkkaClusterHealthCheck(provider, AkkaClusterStatusPolicy.Default);
|
||||
|
||||
var result = await check.CheckHealthAsync(NewContext(check));
|
||||
|
||||
Assert.Equal(HealthStatus.Degraded, result.Status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await system.Terminate();
|
||||
}
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(IHealthCheck check) => new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration("akka-cluster", check, HealthStatus.Unhealthy, tags: null),
|
||||
|
||||
@@ -71,6 +71,21 @@ public sealed class GrpcDependencyHealthCheckTests
|
||||
Assert.Contains("mxaccessgw worker", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeThrowsArbitraryException_Unhealthy()
|
||||
{
|
||||
// A non-RpcException / non-OperationCanceledException (e.g. the transport surfacing an
|
||||
// InvalidOperationException) must be caught and mapped to Unhealthy, not allowed to escape.
|
||||
var result = await RunAsync(new GrpcDependencyOptions
|
||||
{
|
||||
DependencyName = "mxaccessgw worker",
|
||||
Probe = static (_, _) => throw new InvalidOperationException("channel disposed"),
|
||||
});
|
||||
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("mxaccessgw worker", result.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProbeExceedsTimeout_Unhealthy()
|
||||
{
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class ResponseWriterTests
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> GetReadyAsync(
|
||||
HealthStatus status, string description = "db reachable")
|
||||
HealthStatus status, string? description = "db reachable")
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
@@ -66,6 +66,24 @@ public sealed class ResponseWriterTests
|
||||
Assert.Equal("db reachable", db.GetProperty("description").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_NullDescription_EmitsDescriptionKeyAsNull()
|
||||
{
|
||||
// A check that produces no description must still emit the "description" key with a JSON null
|
||||
// value (matching the spec §3 example and the HealthChecks.UI.Client shape) rather than
|
||||
// dropping the key — so consumers can read entries.<name>.description without handling a
|
||||
// missing property.
|
||||
var response = await GetReadyAsync(HealthStatus.Healthy, description: null);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
var db = doc.RootElement.GetProperty("entries").GetProperty("db");
|
||||
|
||||
Assert.True(db.TryGetProperty("description", out var description), "description key must be present");
|
||||
Assert.Equal(JsonValueKind.Null, description.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyEndpoint_Degraded_Returns200_WithDegradedStatus()
|
||||
{
|
||||
|
||||
@@ -127,6 +127,31 @@ public sealed class TierMappingTests
|
||||
Assert.Equal(0, active.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChainedConvention_AppliesToAllThreeEndpoints()
|
||||
{
|
||||
// MapZbHealth returns a composite builder, so a convention chained onto its result
|
||||
// (.RequireHost) must gate all three endpoints — not just readiness. With a host filter
|
||||
// that does not match the default test-client host, every tier returns 404.
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("ready-check", new RecordingHealthCheck(HealthStatus.Healthy), tags: new[] { ZbHealthTags.Ready })
|
||||
.AddCheck("active-check", new RecordingHealthCheck(HealthStatus.Healthy), tags: new[] { ZbHealthTags.Active });
|
||||
|
||||
await using var app = builder.Build();
|
||||
app.MapZbHealth().RequireHost("health.internal");
|
||||
await app.StartAsync();
|
||||
|
||||
var client = app.GetTestClient();
|
||||
|
||||
// The default test host does not match "health.internal", so the convention removed every
|
||||
// endpoint from this host — confirming it fanned out to all three, not just readiness.
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/health/ready")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/health/active")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/healthz")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Options_OverrideRoutePaths()
|
||||
{
|
||||
|
||||
@@ -55,6 +55,14 @@ Trace↔log correlation is automatic: `TraceContextEnricher` reads `Activity.Cur
|
||||
log event and attaches `trace_id` and `span_id`, so log events produced inside a traced request
|
||||
carry the same span identity as the trace backend.
|
||||
|
||||
**Redaction reach.** A registered `ILogRedactor` may **remove** or **replace** any top-level
|
||||
property, and `RedactionEnricher` honours both (a removed key is dropped from the event). The seam
|
||||
sees the unwrapped value of scalar properties only — a destructured `{@Object}` property is exposed
|
||||
as its raw Serilog `StructureValue` wrapper, so a redactor can replace/remove the whole structured
|
||||
property but **cannot** mask a field nested inside it. To protect a sensitive field of a logged
|
||||
object, log it as its own scalar property (do not destructure it) or remove the whole property by
|
||||
key. See the `ILogRedactor` XML doc for the full contract.
|
||||
|
||||
---
|
||||
|
||||
## Exporter options
|
||||
@@ -113,9 +121,9 @@ backend):
|
||||
|
||||
| Assembly | Tests |
|
||||
|---|---|
|
||||
| `ZB.MOM.WW.Telemetry.Tests` | 7 |
|
||||
| `ZB.MOM.WW.Telemetry.Serilog.Tests` | 12 |
|
||||
| **Total** | **19** |
|
||||
| `ZB.MOM.WW.Telemetry.Tests` | 12 |
|
||||
| `ZB.MOM.WW.Telemetry.Serilog.Tests` | 17 |
|
||||
| **Total** | **29** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,20 @@ public interface ILogRedactor
|
||||
/// <summary>
|
||||
/// Inspects and mutates the supplied log-event <paramref name="properties"/> in place — remove
|
||||
/// or replace any sensitive values. Called on every log event before it reaches any sink.
|
||||
/// Both removing a key (the property is dropped from the event) and replacing its value are
|
||||
/// honoured by <see cref="RedactionEnricher"/>.
|
||||
/// <para>
|
||||
/// <b>Reach — scalar top-level properties only.</b> Each entry's value is the unwrapped scalar
|
||||
/// of a Serilog <c>ScalarValue</c> property (so simple string/number/etc. properties such as
|
||||
/// <c>{apiKey}</c> can be read and masked directly). <b>Destructured / structured properties are
|
||||
/// not unwrapped:</b> a <c>{@Object}</c> property arrives as the raw Serilog
|
||||
/// <c>StructureValue</c> wrapper (and a sequence/dictionary as <c>SequenceValue</c>/
|
||||
/// <c>DictionaryValue</c>). A redactor can therefore replace or remove the <i>whole</i>
|
||||
/// top-level property, but it cannot reach a field <i>nested inside</i> a destructured object to
|
||||
/// mask it selectively. To protect a sensitive field of a logged object, do not destructure it
|
||||
/// (log the field as its own scalar property), or remove/replace the entire structured property
|
||||
/// by key.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="properties">The mutable property dictionary for the current log event.</param>
|
||||
void Redact(IDictionary<string, object?> properties);
|
||||
|
||||
@@ -30,8 +30,18 @@ public sealed class RedactionEnricher : ILogEventEnricher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands the log event's scalar properties to the registered <see cref="ILogRedactor"/> and
|
||||
/// writes back any values the redactor changed. No-op when no redactor is registered.
|
||||
/// Hands the log event's properties to the registered <see cref="ILogRedactor"/> and reconciles
|
||||
/// the result back onto the event: values the redactor changed are rewritten via
|
||||
/// <c>AddOrUpdateProperty</c>, and keys the redactor removed are deleted via
|
||||
/// <c>RemovePropertyIfPresent</c>. No-op when no redactor is registered or the event carries no
|
||||
/// properties.
|
||||
/// <para>
|
||||
/// The redactor sees the unwrapped value of each <see cref="ScalarValue"/> property; structured
|
||||
/// values (<see cref="StructureValue"/> from <c>{@Object}</c>, <see cref="SequenceValue"/>,
|
||||
/// <see cref="DictionaryValue"/>) are passed through as their raw Serilog wrapper. A redactor can
|
||||
/// therefore replace or remove a whole structured top-level property, but cannot reach a field
|
||||
/// nested inside one — see <see cref="ILogRedactor"/> for the seam's documented reach.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="logEvent">The log event to redact.</param>
|
||||
/// <param name="propertyFactory">Factory used to materialize replacement properties.</param>
|
||||
@@ -46,6 +56,12 @@ public sealed class RedactionEnricher : ILogEventEnricher
|
||||
return;
|
||||
}
|
||||
|
||||
// Hot path: an event with no properties has nothing to redact — skip the snapshot copy.
|
||||
if (logEvent.Properties.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = new Dictionary<string, object?>(logEvent.Properties.Count);
|
||||
foreach (var property in logEvent.Properties)
|
||||
{
|
||||
@@ -54,6 +70,10 @@ public sealed class RedactionEnricher : ILogEventEnricher
|
||||
: property.Value;
|
||||
}
|
||||
|
||||
// Capture the original key set so we can honour deletions: any key the redactor drops from
|
||||
// the snapshot must be removed from the event (not silently retained).
|
||||
var originalKeys = new HashSet<string>(snapshot.Keys, StringComparer.Ordinal);
|
||||
|
||||
redactor.Redact(snapshot);
|
||||
|
||||
foreach (var entry in snapshot)
|
||||
@@ -64,6 +84,16 @@ public sealed class RedactionEnricher : ILogEventEnricher
|
||||
propertyFactory.CreateProperty(entry.Key, entry.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile removals: a redactor that deleted a key from the snapshot (e.g.
|
||||
// properties.Remove("apiKey")) means that property must not reach any sink.
|
||||
foreach (var key in originalKeys)
|
||||
{
|
||||
if (!snapshot.ContainsKey(key))
|
||||
{
|
||||
logEvent.RemovePropertyIfPresent(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ILogRedactor? ResolveRedactor() => _redactor.Value;
|
||||
|
||||
@@ -115,38 +115,13 @@ internal static class ZbSerilogConfig
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the OTLP Resource-attribute map mirroring <c>ZbResource</c>. Null/empty optional
|
||||
/// attributes are omitted, matching the shared Resource's omission rules. The
|
||||
/// <c>service.instance.id</c> is sourced from <see cref="ZbResource.InstanceId"/> — the
|
||||
/// same deterministic <c>MachineName:ProcessId</c> value used by the OTel SDK path — so
|
||||
/// all three signals carry an identical instance identifier. Internal so it can be asserted
|
||||
/// by the test assembly without being part of the public NuGet API.
|
||||
/// Builds the OTLP log-sink Resource-attribute map. This is <em>not</em> a parallel
|
||||
/// implementation: it is derived directly from <see cref="ZbResource.BuildAttributes"/> — the
|
||||
/// single source of truth shared with the OTel SDK metrics/traces pipeline — so the log sink can
|
||||
/// never drift from metrics and traces. Returned as a fresh mutable copy because the
|
||||
/// Serilog OpenTelemetry sink takes ownership of the dictionary it is handed. Internal so it can
|
||||
/// be asserted by the test assembly without being part of the public NuGet API.
|
||||
/// </summary>
|
||||
internal static IDictionary<string, object> BuildResourceAttributes(ZbTelemetryOptions options)
|
||||
{
|
||||
var attributes = new Dictionary<string, object>
|
||||
{
|
||||
["service.name"] = options.ServiceName,
|
||||
["service.namespace"] = options.ServiceNamespace,
|
||||
["service.instance.id"] = ZbResource.InstanceId,
|
||||
["host.name"] = Environment.MachineName,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ServiceVersion))
|
||||
{
|
||||
attributes["service.version"] = options.ServiceVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.SiteId))
|
||||
{
|
||||
attributes["site.id"] = options.SiteId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.NodeRole))
|
||||
{
|
||||
attributes["node.role"] = options.NodeRole;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
internal static IDictionary<string, object> BuildResourceAttributes(ZbTelemetryOptions options) =>
|
||||
new Dictionary<string, object>(ZbResource.BuildAttributes(options), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ public static class ZbSerilogExtensions
|
||||
var options = new ZbTelemetryOptions();
|
||||
configure(options);
|
||||
|
||||
// Fail fast on a malformed OTLP endpoint with a clear, named message — same validation the
|
||||
// core AddZbTelemetry path uses — instead of a late error when the OTel log sink builds.
|
||||
ZbTelemetryOptionsValidator.Validate(options, nameof(configure));
|
||||
|
||||
// Register the application logger in DI only. preserveStaticLogger: true ensures
|
||||
// AddSerilog does NOT freeze or replace Log.Logger — critical for multi-host
|
||||
// processes (integration tests etc.) where AddZbSerilog may be called more than once.
|
||||
|
||||
@@ -20,6 +20,17 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The Serilog companion package reuses the internal options validator (single fail-fast
|
||||
path); its tests assert it too. -->
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ZB.MOM.WW.Telemetry.Serilog</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
|
||||
<_Parameter1>ZB.MOM.WW.Telemetry.Serilog.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" />
|
||||
|
||||
@@ -9,8 +9,10 @@ namespace ZB.MOM.WW.Telemetry;
|
||||
public static class ZbMetricsEndpointExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Mounts the Prometheus <c>/metrics</c> endpoint. Only valid when
|
||||
/// <see cref="ZbTelemetryOptions.Exporter"/> = <see cref="ZbExporter.Prometheus"/>.
|
||||
/// Mounts the Prometheus <c>/metrics</c> endpoint. Valid under <em>any</em>
|
||||
/// <see cref="ZbTelemetryOptions.Exporter"/> value: the Prometheus exporter is always wired by
|
||||
/// <c>AddZbTelemetry</c>, and OTLP (<see cref="ZbExporter.Otlp"/>) is only an additive overlay —
|
||||
/// so <c>/metrics</c> serves scrape data even when <c>Exporter = ZbExporter.Otlp</c>.
|
||||
/// Call after <c>app.UseRouting()</c>.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
|
||||
@@ -31,34 +31,55 @@ public static class ZbResource
|
||||
Configure(ResourceBuilder.CreateDefault(), options);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the shared ZB.MOM.WW Resource attributes to an existing <see cref="ResourceBuilder"/>.
|
||||
/// Internal seam so the <c>AddZbTelemetry</c> pipeline produces a Resource identical to
|
||||
/// <see cref="Build"/>.
|
||||
/// The single source of truth for the shared ZB.MOM.WW Resource attribute set. Every consumer
|
||||
/// of the Resource — the OTel SDK metrics/traces pipeline (<see cref="Configure"/>) and the
|
||||
/// Serilog OTLP log sink — derives its attributes from this one map, so logs can never drift
|
||||
/// from metrics/traces. Required attributes (<c>service.name</c>, <c>service.namespace</c>,
|
||||
/// <c>service.instance.id</c>, <c>host.name</c>) are always present; optional ones
|
||||
/// (<c>service.version</c>, <c>site.id</c>, <c>node.role</c>) are included only when the
|
||||
/// corresponding option is non-null/non-empty, matching the Resource's omission rules.
|
||||
/// </summary>
|
||||
internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
|
||||
/// <param name="options">The telemetry options describing the service identity.</param>
|
||||
/// <returns>The canonical attribute map carried by all three signals.</returns>
|
||||
public static IReadOnlyDictionary<string, object> BuildAttributes(ZbTelemetryOptions options)
|
||||
{
|
||||
builder.AddService(
|
||||
serviceName: options.ServiceName,
|
||||
serviceNamespace: options.ServiceNamespace,
|
||||
serviceVersion: options.ServiceVersion,
|
||||
autoGenerateServiceInstanceId: false,
|
||||
serviceInstanceId: InstanceId);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var attributes = new List<KeyValuePair<string, object>>
|
||||
var attributes = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
new("host.name", System.Environment.MachineName),
|
||||
["service.name"] = options.ServiceName,
|
||||
["service.namespace"] = options.ServiceNamespace,
|
||||
["service.instance.id"] = InstanceId,
|
||||
["host.name"] = System.Environment.MachineName,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ServiceVersion))
|
||||
{
|
||||
attributes["service.version"] = options.ServiceVersion;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.SiteId))
|
||||
{
|
||||
attributes.Add(new("site.id", options.SiteId));
|
||||
attributes["site.id"] = options.SiteId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(options.NodeRole))
|
||||
{
|
||||
attributes.Add(new("node.role", options.NodeRole));
|
||||
attributes["node.role"] = options.NodeRole;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the shared ZB.MOM.WW Resource attributes to an existing <see cref="ResourceBuilder"/>.
|
||||
/// Internal seam so the <c>AddZbTelemetry</c> pipeline produces a Resource identical to
|
||||
/// <see cref="Build"/>. Derives every attribute from <see cref="BuildAttributes"/> — the same
|
||||
/// canonical map the Serilog OTLP log sink uses — so all three signals agree.
|
||||
/// </summary>
|
||||
internal static ResourceBuilder Configure(ResourceBuilder builder, ZbTelemetryOptions options)
|
||||
{
|
||||
var attributes = BuildAttributes(options);
|
||||
builder.AddAttributes(attributes);
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ public static class ZbTelemetryExtensions
|
||||
"ZbTelemetryOptions.ServiceName is required (e.g. \"otopcua\").",
|
||||
nameof(configure));
|
||||
}
|
||||
ZbTelemetryOptionsValidator.Validate(options, nameof(configure));
|
||||
return options;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.Telemetry;
|
||||
|
||||
/// <summary>
|
||||
/// Eager, fail-fast validation of <see cref="ZbTelemetryOptions"/> shared by the core
|
||||
/// <c>AddZbTelemetry</c> path and the Serilog <c>AddZbSerilog</c> path, so a malformed value is
|
||||
/// reported once, clearly, and with the offending option named — rather than surfacing late as a
|
||||
/// bare <see cref="UriFormatException"/> deep inside exporter construction at host-build time.
|
||||
/// </summary>
|
||||
internal static class ZbTelemetryOptionsValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the OTLP configuration. When <see cref="ZbTelemetryOptions.Exporter"/> is
|
||||
/// <see cref="ZbExporter.Otlp"/>, <see cref="ZbTelemetryOptions.OtlpEndpoint"/> must be a
|
||||
/// non-empty, well-formed absolute URI. Throws an <see cref="ArgumentException"/> (naming the
|
||||
/// option) otherwise. No-op for the Prometheus exporter — a stray endpoint is ignored there.
|
||||
/// </summary>
|
||||
/// <param name="options">The populated telemetry options to validate.</param>
|
||||
/// <param name="paramName">The originating parameter name for the thrown exception.</param>
|
||||
public static void Validate(ZbTelemetryOptions options, string paramName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (options.Exporter != ZbExporter.Otlp)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.OtlpEndpoint))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"ZbTelemetryOptions.OtlpEndpoint is required when Exporter = ZbExporter.Otlp " +
|
||||
"(e.g. \"http://collector:4317\").",
|
||||
paramName);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(options.OtlpEndpoint, UriKind.Absolute, out _))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"ZbTelemetryOptions.OtlpEndpoint is not a well-formed absolute URI: " +
|
||||
$"'{options.OtlpEndpoint}' (expected e.g. \"http://collector:4317\").",
|
||||
paramName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,30 @@ public sealed class RedactionTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RemovingRedactor : ILogRedactor
|
||||
{
|
||||
private readonly string _key;
|
||||
|
||||
public RemovingRedactor(string key) => _key = key;
|
||||
|
||||
public void Redact(IDictionary<string, object?> properties) => properties.Remove(_key);
|
||||
}
|
||||
|
||||
private sealed class StructuredFieldRedactor : ILogRedactor
|
||||
{
|
||||
// Attempts to mask a nested field of a destructured ({@Object}) property by mutating the
|
||||
// value the seam exposes. Documents that the seam reaches scalar top-level properties only.
|
||||
public void Redact(IDictionary<string, object?> properties)
|
||||
{
|
||||
if (properties.TryGetValue("command", out var value) && value is StructureValue)
|
||||
{
|
||||
// The seam exposes the raw StructureValue wrapper, not a mutable dictionary of the
|
||||
// object's fields, so a project redactor cannot reach inside to mask a nested field.
|
||||
properties["command"] = Masked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ScalarOrNull(LogEvent logEvent, string propertyName) =>
|
||||
logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar
|
||||
? scalar.Value?.ToString()
|
||||
@@ -68,6 +92,68 @@ public sealed class RedactionTests
|
||||
Assert.Equal("mxgw_secret", ScalarOrNull(logEvent, "apiKey"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry-001: a redactor that REMOVES a key (the most natural way to implement "must not
|
||||
/// leave the process") must result in the property being absent from the emitted event, not
|
||||
/// silently retained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Removing_redactor_scrubs_the_property_from_the_event()
|
||||
{
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<ILogRedactor>(new RemovingRedactor("apiKey"))
|
||||
.BuildServiceProvider();
|
||||
|
||||
var sink = new InMemorySink();
|
||||
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
|
||||
|
||||
var loggerConfig = new LoggerConfiguration();
|
||||
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
|
||||
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
|
||||
|
||||
logger.Information("authenticating {apiKey} for {user}", "mxgw_secret", "alice");
|
||||
|
||||
var logEvent = Assert.Single(sink.LogEvents);
|
||||
Assert.False(
|
||||
logEvent.Properties.ContainsKey("apiKey"),
|
||||
"apiKey must be removed from the event when the redactor removes the key");
|
||||
// A non-sensitive property the redactor left alone must survive.
|
||||
Assert.Equal("alice", ScalarOrNull(logEvent, "user"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry-002/003: the redaction seam reaches scalar top-level properties only. A
|
||||
/// destructured ({@Object}) property is exposed to the redactor as the raw Serilog
|
||||
/// <see cref="StructureValue"/> wrapper, so a project redactor cannot mask a field nested
|
||||
/// inside the object — it can only replace/remove the whole top-level property. This test
|
||||
/// pins that documented limitation (see ILogRedactor XML doc and the shared contract).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Redactor_cannot_reach_a_field_inside_a_destructured_object()
|
||||
{
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton<ILogRedactor>(new StructuredFieldRedactor())
|
||||
.BuildServiceProvider();
|
||||
|
||||
var sink = new InMemorySink();
|
||||
var options = new ZbTelemetryOptions { ServiceName = "mxgateway" };
|
||||
|
||||
var loggerConfig = new LoggerConfiguration();
|
||||
ZbSerilogConfig.Apply(loggerConfig, options, serviceProvider);
|
||||
using Logger logger = loggerConfig.WriteTo.Sink(sink).CreateLogger();
|
||||
|
||||
var command = new { Name = "Write", ApiKey = "mxgw_secret" };
|
||||
logger.Information("dispatching {@command}", command);
|
||||
|
||||
var logEvent = Assert.Single(sink.LogEvents);
|
||||
Assert.True(logEvent.Properties.TryGetValue("command", out var value));
|
||||
|
||||
// The property was destructured into a StructureValue and exposed to the redactor as that
|
||||
// wrapper. The redactor recognized it and replaced the whole top-level property with the
|
||||
// mask — confirming the seam can only act at top-level granularity for structured values.
|
||||
Assert.Equal(Masked, (value as ScalarValue)?.Value?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbSerilog_with_otlp_options_builds_without_error()
|
||||
{
|
||||
@@ -122,6 +208,32 @@ public sealed class RedactionTests
|
||||
Assert.Equal("central", attributes["node.role"]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry-005: the Serilog OTLP log-sink attribute map and the OTel SDK metrics/traces
|
||||
/// attribute map must be key-for-key and value-for-value identical, because both now derive from
|
||||
/// the single <see cref="ZbResource.BuildAttributes"/> source of truth. This pins that they
|
||||
/// cannot silently drift apart.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Serilog_and_OTel_resource_attribute_sets_are_identical()
|
||||
{
|
||||
var options = new ZbTelemetryOptions
|
||||
{
|
||||
ServiceName = "mxgateway",
|
||||
ServiceNamespace = "ZB.MOM.WW",
|
||||
ServiceVersion = "9.9.9",
|
||||
SiteId = "site-z",
|
||||
NodeRole = "hub",
|
||||
};
|
||||
|
||||
var serilogAttributes = ZbSerilogConfig.BuildResourceAttributes(options);
|
||||
var canonical = ZbResource.BuildAttributes(options);
|
||||
|
||||
Assert.Equal(
|
||||
canonical.OrderBy(kvp => kvp.Key, StringComparer.Ordinal),
|
||||
serilogAttributes.OrderBy(kvp => kvp.Key, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildResourceAttributes_omits_optional_keys_when_not_set()
|
||||
{
|
||||
|
||||
@@ -37,6 +37,55 @@ public sealed class AddZbTelemetryTests
|
||||
Assert.Equal("configure", ex.ParamName);
|
||||
}
|
||||
|
||||
// Telemetry-006: malformed/missing OtlpEndpoint must fail fast with a clear, named error
|
||||
// instead of a late UriFormatException deep inside exporter construction.
|
||||
|
||||
[Fact]
|
||||
public void AddZbTelemetry_Throws_WhenOtlpExporterHasMalformedEndpoint()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "telemetry-test";
|
||||
o.Exporter = ZbExporter.Otlp;
|
||||
o.OtlpEndpoint = "not a uri"; // missing scheme — not an absolute URI
|
||||
}));
|
||||
Assert.Equal("configure", ex.ParamName);
|
||||
Assert.Contains("OtlpEndpoint", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbTelemetry_Throws_WhenOtlpExporterHasNoEndpoint()
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
builder.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "telemetry-test";
|
||||
o.Exporter = ZbExporter.Otlp;
|
||||
// OtlpEndpoint left null
|
||||
}));
|
||||
Assert.Equal("configure", ex.ParamName);
|
||||
Assert.Contains("OtlpEndpoint", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddZbTelemetry_DoesNotValidateEndpoint_WhenExporterIsPrometheus()
|
||||
{
|
||||
// A stray (even malformed) endpoint is harmless under the Prometheus exporter and must not
|
||||
// be validated — it is ignored.
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
var ex = Record.Exception(() =>
|
||||
builder.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "telemetry-test";
|
||||
o.Exporter = ZbExporter.Prometheus;
|
||||
o.OtlpEndpoint = "not a uri";
|
||||
}));
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// Fix #1: Prometheus coexists with OTLP — /metrics must still serve under Otlp exporter
|
||||
|
||||
[Fact]
|
||||
@@ -48,8 +97,10 @@ public sealed class AddZbTelemetryTests
|
||||
{
|
||||
o.ServiceName = "telemetry-test";
|
||||
o.Exporter = ZbExporter.Otlp;
|
||||
// OtlpEndpoint intentionally left null — exporter will be registered but won't
|
||||
// connect anywhere; we are only verifying Prometheus remains present.
|
||||
// A well-formed endpoint is required under the Otlp exporter (Telemetry-006); the
|
||||
// exporter is registered but won't connect anywhere in the test. We are only verifying
|
||||
// Prometheus remains present.
|
||||
o.OtlpEndpoint = "http://localhost:4317";
|
||||
o.Meters = ["Test.OtlpCoexist.Meter"];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ woff2 fonts, and side-rail layout CSS — all served from `_content/ZB.MOM.WW.Th
|
||||
plus a set of Blazor SSR components that carry no inline colours and reuse the token
|
||||
classes. Bootstrap 5 is **not** vendored; each app keeps its own Bootstrap link.
|
||||
|
||||
The kit ships **no JavaScript**: every interactive affordance — the narrow-viewport
|
||||
side-rail hamburger and the collapsible nav sections — is a native CSS-only
|
||||
`<details>`/`<summary>` disclosure, so the chrome works unchanged in static Blazor SSR
|
||||
with only Bootstrap **CSS** present (no Bootstrap collapse JS bundle required).
|
||||
|
||||
## Adopt
|
||||
|
||||
1. Reference the NuGet package in your app; keep your own Bootstrap 5 `<link>` in `App.razor`.
|
||||
@@ -83,11 +88,16 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
|
||||
`theme.css` declares `@font-face` with `url('../fonts/…')` — correct relative path from
|
||||
`css/` to `fonts/`. (OtOpcUa's original `url('fonts/…')` was a latent 404; the kit fixes it.)
|
||||
|
||||
All colours are CSS custom-property tokens declared once in `theme.css`'s `:root` block —
|
||||
no hardcoded hex appears outside it. Derived border/hover/wash shades (e.g. `--ok-border`,
|
||||
`--warn-ink`, `--info-bg`, `--hover-bg`, `--active-bg`, `--zebra-bg`) are named tokens too,
|
||||
so a token-block edit re-themes the whole kit.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# from ZB.MOM.WW.Theme/
|
||||
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
|
||||
dotnet test # 32 bUnit tests
|
||||
dotnet test # 38 bUnit tests
|
||||
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
|
||||
```
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
|
||||
/// <summary>
|
||||
/// Visual variant for <c>TechButton</c>, mapped to a Bootstrap <c>.btn-*</c> class.
|
||||
/// </summary>
|
||||
public enum ButtonVariant
|
||||
{
|
||||
/// <summary>Primary action — emits <c>btn-primary</c> (filled accent button).</summary>
|
||||
Primary,
|
||||
|
||||
/// <summary>Secondary action — emits <c>btn-outline-secondary</c> (outlined button).</summary>
|
||||
Secondary,
|
||||
|
||||
/// <summary>Destructive action — emits <c>btn-danger</c> (filled red button).</summary>
|
||||
Danger,
|
||||
|
||||
/// <summary>Low-emphasis action — emits <c>btn-link</c> (text-only button).</summary>
|
||||
Ghost,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Product name displayed next to the brand glyph. Required.</summary>
|
||||
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom logo. When provided it replaces the default brand glyph (<c>▐</c>).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Logo { get; set; }
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Product name shown in the card heading. Required.</summary>
|
||||
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
|
||||
/// </summary>
|
||||
[Parameter] public string Action { get; set; } = "/auth/login";
|
||||
|
||||
/// <summary>
|
||||
@@ -47,6 +52,9 @@
|
||||
/// </summary>
|
||||
[Parameter] public string? ReturnUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional error message shown as a notice above the submit button (e.g. a failed login).
|
||||
/// </summary>
|
||||
[Parameter] public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,8 +6,21 @@
|
||||
</NavLink>
|
||||
|
||||
@code {
|
||||
/// <summary>Link target (the <c>href</c> of the underlying <c>NavLink</c>). Required.</summary>
|
||||
[Parameter, EditorRequired] public string Href { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Visible label text for the rail link. Required.</summary>
|
||||
[Parameter, EditorRequired] public string Text { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional leading icon, rendered in a <c>.rail-ico</c> span before the label.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Icon { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Active-class matching behaviour passed to the underlying <c>NavLink</c>.
|
||||
/// Defaults to <see cref="NavLinkMatch.Prefix"/>; use <see cref="NavLinkMatch.All"/>
|
||||
/// for exact matches such as the home/overview link.
|
||||
/// </summary>
|
||||
[Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,24 @@
|
||||
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
|
||||
@namespace ZB.MOM.WW.Theme
|
||||
<details class="rail-section" open="@Expanded">
|
||||
<summary class="rail-eyebrow-toggle"><span class="rail-eyebrow-label">@Title</span></summary>
|
||||
<summary class="rail-eyebrow-toggle">@Title</summary>
|
||||
<div class="rail-section-body">@ChildContent</div>
|
||||
</details>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Eyebrow label shown in the section's <c><summary></c> header. Required.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initial open state of the collapsible section. Defaults to <c>true</c> (expanded).
|
||||
/// Backed by the native <c><details open></c> attribute — no JavaScript required.
|
||||
/// </summary>
|
||||
[Parameter] public bool Expanded { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Section content — typically <see cref="NavRailItem"/> children.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
<span class="chip @ChipClass">@ChildContent</span>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Status the chip represents. Mapped to a token-based <c>.chip-*</c> class
|
||||
/// (e.g. <see cref="StatusState.Ok"/> → <c>chip-ok</c>). Required.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public StatusState State { get; set; }
|
||||
|
||||
/// <summary>Chip label content (e.g. the status text).</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private string ChipClass => State switch
|
||||
|
||||
@@ -6,10 +6,28 @@
|
||||
</button>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Visual variant, mapped to a Bootstrap <c>.btn-*</c> class.
|
||||
/// Defaults to <see cref="ButtonVariant.Primary"/>.
|
||||
/// </summary>
|
||||
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
|
||||
|
||||
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
|
||||
[Parameter] public string Type { get; set; } = "button";
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, disables the button and shows an inline spinner — use while an
|
||||
/// async action is in flight. Defaults to <c>false</c>.
|
||||
/// </summary>
|
||||
[Parameter] public bool Busy { get; set; }
|
||||
|
||||
/// <summary>Button label content.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary additional HTML attributes splatted onto the underlying
|
||||
/// <c><button></c> (e.g. <c>id</c>, <c>@onclick</c>, <c>name</c>, <c>value</c>).
|
||||
/// </summary>
|
||||
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
|
||||
|
||||
private string VariantClass => Variant switch
|
||||
|
||||
@@ -8,9 +8,23 @@
|
||||
</section>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Optional string title rendered in the panel header. Ignored when <see cref="Header"/>
|
||||
/// is supplied (<see cref="Header"/> takes precedence).
|
||||
/// </summary>
|
||||
[Parameter] public string? Title { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom panel header content. Takes precedence over <see cref="Title"/>.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Header { get; set; }
|
||||
|
||||
/// <summary>Panel body content.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
/// <summary>Optional panel footer content (padded, top-bordered).</summary>
|
||||
[Parameter] public RenderFragment? Footer { get; set; }
|
||||
|
||||
/// <summary>Additional CSS classes appended to the root <c><section class="panel"></c>.</summary>
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,8 +8,17 @@
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>Field label text. Required.</summary>
|
||||
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional hint text rendered as <c>.form-text</c> below the input.</summary>
|
||||
[Parameter] public string? Hint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional inline error message rendered as <c>.field-error.s-bad</c> below the input.
|
||||
/// </summary>
|
||||
[Parameter] public string? Error { get; set; }
|
||||
|
||||
/// <summary>The control itself — an <c><input></c>, <c><select></c>, etc.</summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
@* Components/ThemeShell.razor — the one canonical side-rail chassis.
|
||||
Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
|
||||
Not a LayoutComponentBase: the app's thin MainLayout delegates to this.
|
||||
The narrow-viewport hamburger uses a CSS-only <details> disclosure (no
|
||||
Bootstrap JS): the rail is force-shown on lg+ and toggles via the native
|
||||
<details> open state below lg, so it works in pure static SSR. *@
|
||||
@namespace ZB.MOM.WW.Theme
|
||||
<div class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
|
||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
type="button" data-bs-toggle="collapse" data-bs-target="#theme-rail"
|
||||
aria-controls="theme-rail" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<details class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
|
||||
<summary class="rail-toggle btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
||||
aria-label="Toggle navigation">
|
||||
☰
|
||||
</button>
|
||||
<div class="collapse d-lg-block" id="theme-rail">
|
||||
</summary>
|
||||
<div class="theme-rail" id="theme-rail">
|
||||
<nav class="side-rail">
|
||||
<BrandBar Product="@Product" Logo="@Logo" />
|
||||
@Nav
|
||||
@@ -18,14 +20,41 @@
|
||||
</nav>
|
||||
</div>
|
||||
<main class="page">@ChildContent</main>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@code {
|
||||
/// <summary>
|
||||
/// Product name shown in the rail's <see cref="BrandBar"/> header. Required.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-app accent colour. When set, emitted as <c>style="--accent: <value>"</c>
|
||||
/// on the shell root, overriding the <c>--accent</c> design token for this subtree only.
|
||||
/// This is the single per-app token override the kit allows (e.g. <c>"#2f855a"</c>).
|
||||
/// </summary>
|
||||
[Parameter] public string? Accent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom logo rendered in the rail header. When omitted, the default
|
||||
/// brand glyph is shown.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Logo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rail navigation content — typically <see cref="NavRailSection"/> /
|
||||
/// <see cref="NavRailItem"/> elements.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? Nav { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional content pinned to the bottom of the rail (e.g. session info / sign-out).
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? RailFooter { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The page body — normally <c>@Body</c> forwarded from the app's <c>MainLayout</c>.
|
||||
/// </summary>
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
namespace ZB.MOM.WW.Theme;
|
||||
|
||||
public enum StatusState { Ok, Warn, Bad, Idle, Info }
|
||||
/// <summary>
|
||||
/// Status conveyed by a <c>StatusPill</c> chip, mapped to a token-based
|
||||
/// <c>.chip-*</c> class (status is communicated by colour, not iconography).
|
||||
/// </summary>
|
||||
public enum StatusState
|
||||
{
|
||||
/// <summary>Success / healthy / connected — emits <c>chip-ok</c> (green).</summary>
|
||||
Ok,
|
||||
|
||||
/// <summary>Warning / degraded — emits <c>chip-warn</c> (amber).</summary>
|
||||
Warn,
|
||||
|
||||
/// <summary>Error / faulted / disconnected — emits <c>chip-bad</c> (red).</summary>
|
||||
Bad,
|
||||
|
||||
/// <summary>Unknown / offline / neutral — emits <c>chip-idle</c> (grey). The default.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Informational — emits <c>chip-info</c> (on-palette blue).</summary>
|
||||
Info,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
<PropertyGroup>
|
||||
<RootNamespace>ZB.MOM.WW.Theme</RootNamespace>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<!-- Emit the XML doc file so missing-doc (CS1591) warnings on the public API
|
||||
surface under TreatWarningsAsErrors. Razor compiles each component to a
|
||||
public class whose generated members carry no docs, so CS1591 is left
|
||||
out of the error set (it would flag generated members, not authored ones)
|
||||
while still producing the doc XML consumers see at the call site. -->
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.Theme</PackageId>
|
||||
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
Tokens live in theme.css; this sheet carries only layout + the side rail. */
|
||||
|
||||
/* ── App shell: side rail + page ─────────────────────────────────────────── */
|
||||
/* The outer flex direction is supplied by Bootstrap utilities on the wrapper
|
||||
(`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above
|
||||
the rail on <lg viewports and the rail sits beside the page on lg+. */
|
||||
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
|
||||
toggle works with NO JavaScript (no Bootstrap collapse bundle): below lg the
|
||||
<summary> hamburger opens/closes the rail; on lg+ the rail is force-shown
|
||||
regardless of the open state (see the lg media query below). The outer flex
|
||||
direction is supplied by Bootstrap utilities on the wrapper
|
||||
(`d-flex flex-column flex-lg-row`) so the hamburger row stacks above the rail
|
||||
on <lg viewports and the rail sits beside the page on lg+. */
|
||||
.app-shell {
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 3.3rem);
|
||||
@@ -15,6 +19,18 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hamburger <summary>: hide the native disclosure triangle so it reads as a
|
||||
plain button (the styling comes from the Bootstrap .btn utilities). */
|
||||
.app-shell > summary.rail-toggle {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
|
||||
|
||||
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing
|
||||
extra is needed there. On lg+ we force the rail visible (see media query)
|
||||
and keep the toggle hidden via Bootstrap's d-lg-none. */
|
||||
|
||||
/* ── Side rail ───────────────────────────────────────────────────────────── */
|
||||
.side-rail {
|
||||
width: 220px;
|
||||
@@ -27,9 +43,12 @@
|
||||
border-right: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
|
||||
/* On lg+ keep the side rail pinned so it stays visible when content scrolls,
|
||||
and force it shown regardless of the <details> open state (the hamburger
|
||||
toggle is hidden at this width). */
|
||||
@media (min-width: 992px) {
|
||||
#theme-rail {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
@@ -38,8 +57,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* When the side rail is collapsed under <lg viewports the Bootstrap collapse
|
||||
container removes the fixed width; restore full width on mobile. */
|
||||
/* When the side rail is collapsed under <lg viewports (the <details> is closed)
|
||||
the native disclosure hides #theme-rail; when open, restore full width on
|
||||
mobile so the rail spans the viewport. */
|
||||
@media (max-width: 991.98px) {
|
||||
.side-rail {
|
||||
width: 100%;
|
||||
@@ -72,17 +92,9 @@
|
||||
}
|
||||
.side-rail .brand .mark { color: var(--accent); }
|
||||
|
||||
.rail-eyebrow {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ink-faint);
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Collapsible variant — rendered by NavRailSection. Looks like .rail-eyebrow
|
||||
plus a leading chevron; clicking flips chevron + expanded state. */
|
||||
/* Section eyebrow — the <summary> label of a NavRailSection. The collapse
|
||||
chevron is supplied by the `.rail-section > summary::before` rule below; this
|
||||
rule styles the uppercase eyebrow text itself. */
|
||||
.rail-eyebrow-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -100,12 +112,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.rail-eyebrow-toggle:hover { color: var(--ink); }
|
||||
.rail-eyebrow-chevron {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
font-size: 0.55rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.rail-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -120,17 +126,28 @@
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.rail-link:hover {
|
||||
background: #f3f6fd;
|
||||
background: var(--hover-bg);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.rail-link.active {
|
||||
background: #eef2fc;
|
||||
background: var(--active-bg);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--accent-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Optional leading icon inside a rail link (NavRailItem's Icon slot). Sizes and
|
||||
aligns the icon and gives it a gap from the label text. */
|
||||
.rail-ico {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
margin-right: 0.4rem;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
/* ── Session block, pinned to the rail foot ──────────────────────────────── */
|
||||
.rail-foot {
|
||||
margin-top: auto;
|
||||
@@ -180,8 +197,8 @@
|
||||
.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; }
|
||||
.rail-section[open] > summary::before { content: '\25BC'; }
|
||||
|
||||
/* StatusPill: info variant (on-palette, reuses dir-read colours) */
|
||||
.chip-info { color: var(--accent-deep); background: #e7ecfb; border-color: #cdd9f7; }
|
||||
/* StatusPill: info variant (on-palette, reuses the info blue wash) */
|
||||
.chip-info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
|
||||
|
||||
/* TechCard body/footer padding; TechField error; LoginCard body */
|
||||
.panel-body { padding: 0.85rem 0.9rem; }
|
||||
|
||||
@@ -62,6 +62,29 @@
|
||||
--bad-bg: #fceaea;
|
||||
--idle-bg: #eef0f2;
|
||||
|
||||
/* Status — derived border tints (lighter than the foreground; for chips,
|
||||
pills, and tinted cards). Pair with the matching -bg above. */
|
||||
--ok-border: #c6e6cd;
|
||||
--warn-border: #efd6a6;
|
||||
--bad-border: #eec3c3;
|
||||
--ok-border-soft: #bfe3c6; /* conn pill (slightly lighter) */
|
||||
--warn-border-soft: #f0d9ab; /* conn pill (slightly lighter) */
|
||||
--bad-border-soft: #f0c0c0; /* conn pill (slightly lighter) */
|
||||
|
||||
/* Warning ink — a deeper amber than --warn, for chip/notice/value text that
|
||||
needs more contrast on the warm tint than the pure --warn would give. */
|
||||
--warn-ink: #b56a00;
|
||||
--warn-ink-deep: #8a5a00;
|
||||
|
||||
/* Info / accent tint — on-palette blue wash (StatusPill Info, dir-read tag) */
|
||||
--info-bg: #e7ecfb;
|
||||
--info-border: #cdd9f7;
|
||||
|
||||
/* Neutral surface washes — derived from --paper/--accent, no own foreground */
|
||||
--zebra-bg: #fbfbf9; /* even-row / sticky-head fill — barely-there */
|
||||
--hover-bg: #f3f6fd; /* row / rail-link hover — faint cool wash */
|
||||
--active-bg: #eef2fc; /* active rail-link fill */
|
||||
|
||||
/* Type stacks — Plex first, graceful system fallback */
|
||||
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
|
||||
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
@@ -81,7 +104,7 @@
|
||||
not a gradient wash. Keep it subtle. */
|
||||
body {
|
||||
background:
|
||||
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
|
||||
radial-gradient(1200px 480px at 88% -8%, var(--card) 0%, rgba(255,255,255,0) 70%),
|
||||
var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--sans);
|
||||
@@ -142,11 +165,11 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--idle);
|
||||
}
|
||||
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
|
||||
.conn-pill[data-state="connected"] { color: var(--ok); border-color: var(--ok-border-soft); background: var(--ok-bg); }
|
||||
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
|
||||
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
|
||||
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: var(--warn-border-soft); background: var(--warn-bg); }
|
||||
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
|
||||
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
|
||||
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: var(--bad-border-soft); background: var(--bad-bg); }
|
||||
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
|
||||
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
@@ -171,10 +194,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
|
||||
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
|
||||
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
|
||||
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
|
||||
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: var(--ok-border); }
|
||||
.chip-warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
|
||||
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); }
|
||||
.chip-idle { color: var(--idle); background: var(--idle-bg); border-color: var(--rule-strong); }
|
||||
|
||||
/* ── Panel — the base raised surface ─────────────────────────────────────────
|
||||
A white card with a hairline border and 8px radius. .panel-head is the
|
||||
@@ -248,10 +271,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
font-weight: 400;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
|
||||
.agg-card.alert { border-color: var(--bad-border); background: var(--bad-bg); }
|
||||
.agg-card.alert .agg-value { color: var(--bad); }
|
||||
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
|
||||
.agg-card.caution .agg-value { color: #b56a00; }
|
||||
.agg-card.caution { border-color: var(--warn-border); background: var(--warn-bg); }
|
||||
.agg-card.caution .agg-value { color: var(--warn-ink); }
|
||||
|
||||
/* ── Metric card + key/value rows ────────────────────────────────────────────
|
||||
A .panel-head over a stack of .kv rows: label left, monospace value right.
|
||||
@@ -278,7 +301,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
padding: 0.32rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.kv:nth-child(even) { background: #fbfbf9; }
|
||||
.kv:nth-child(even) { background: var(--zebra-bg); }
|
||||
.kv .k { color: var(--ink-soft); }
|
||||
.kv .v {
|
||||
font-family: var(--mono);
|
||||
@@ -332,7 +355,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-faint);
|
||||
background: #fbfbf9;
|
||||
background: var(--zebra-bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
@@ -345,7 +368,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
|
||||
|
||||
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
|
||||
.data-table tbody tr:hover { background: #f3f6fd; }
|
||||
.data-table tbody tr:hover { background: var(--hover-bg); }
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.empty-row {
|
||||
@@ -365,15 +388,15 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
|
||||
.dir-write { color: #8a5a00; background: var(--warn-bg); }
|
||||
.dir-read { color: var(--accent-deep); background: var(--info-bg); }
|
||||
.dir-write { color: var(--warn-ink-deep); background: var(--warn-bg); }
|
||||
|
||||
/* ── Inline notice ───────────────────────────────────────────────────────────
|
||||
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
|
||||
.notice {
|
||||
padding: 0.85rem 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #b56a00;
|
||||
color: var(--warn-ink);
|
||||
background: var(--warn-bg);
|
||||
border-color: #efd6a6;
|
||||
border-color: var(--warn-border);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,28 @@ public class NavRailTests : TestContext
|
||||
Assert.Contains("Clusters", a.TextContent);
|
||||
}
|
||||
|
||||
// Theme-004: when Icon is supplied it is wrapped in a .rail-ico span (now styled).
|
||||
[Fact]
|
||||
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters")
|
||||
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
|
||||
var ico = cut.Find("a.rail-link .rail-ico");
|
||||
Assert.NotNull(ico);
|
||||
Assert.NotNull(cut.Find("a.rail-link .rail-ico .ico"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavRailItem_omits_rail_ico_span_when_no_icon()
|
||||
{
|
||||
var cut = RenderComponent<NavRailItem>(p => p
|
||||
.Add(x => x.Href, "/clusters")
|
||||
.Add(x => x.Text, "Clusters"));
|
||||
Assert.Empty(cut.FindAll(".rail-ico"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NavRailSection_renders_title_and_children_open_by_default()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.Theme.Tests;
|
||||
|
||||
@@ -32,4 +34,36 @@ public class StaticAssetsTests
|
||||
[InlineData("ibm-plex-mono-500.woff2")]
|
||||
public void Fonts_are_vendored(string file) =>
|
||||
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
|
||||
|
||||
// Theme-002: .chip-idle pairs the idle background with the matching --idle
|
||||
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
|
||||
[Fact]
|
||||
public void ChipIdle_pairs_idle_foreground_with_idle_background()
|
||||
{
|
||||
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css"));
|
||||
var rule = Regex.Match(css, @"\.chip-idle\s*\{[^}]*\}").Value;
|
||||
Assert.Contains("color: var(--idle)", rule);
|
||||
Assert.Contains("background: var(--idle-bg)", rule);
|
||||
Assert.DoesNotContain("--ink-soft", rule);
|
||||
}
|
||||
|
||||
// Theme-003: no hardcoded hex colours appear outside the :root token block in
|
||||
// either stylesheet — every shade is a named token.
|
||||
[Theory]
|
||||
[InlineData("theme.css")]
|
||||
[InlineData("layout.css")]
|
||||
public void No_hardcoded_hex_outside_root_token_block(string file)
|
||||
{
|
||||
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", file));
|
||||
|
||||
// Strip the :root { ... } declaration block(s) — the one place hex literals live.
|
||||
var withoutRoot = Regex.Replace(css, @":root\s*\{[^}]*\}", string.Empty);
|
||||
|
||||
var hexLiterals = Regex.Matches(withoutRoot, @"#[0-9a-fA-F]{3,8}\b")
|
||||
.Select(m => m.Value)
|
||||
.ToList();
|
||||
|
||||
Assert.True(hexLiterals.Count == 0,
|
||||
$"{file} has hardcoded hex outside :root: {string.Join(", ", hexLiterals)}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,4 +40,26 @@ public class ThemeShellTests : TestContext
|
||||
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
|
||||
Assert.NotNull(cut.Find(".rail-foot .sess"));
|
||||
}
|
||||
|
||||
// Theme-001: the mobile hamburger must work without Bootstrap collapse JS.
|
||||
// The shell is a native <details> disclosure whose <summary> is the toggle —
|
||||
// no data-bs-toggle / data-bs-target attributes, so no Bootstrap JS dependency.
|
||||
[Fact]
|
||||
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
|
||||
{
|
||||
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
|
||||
|
||||
// Root is a <details> whose direct <summary> is the hamburger toggle.
|
||||
var shell = cut.Find("details.app-shell");
|
||||
var summary = cut.Find("details.app-shell > summary.rail-toggle");
|
||||
Assert.NotNull(summary);
|
||||
|
||||
// The rail is plain markup inside the <details> — no Bootstrap .collapse class.
|
||||
var rail = cut.Find("#theme-rail");
|
||||
Assert.DoesNotContain("collapse", rail.ClassList);
|
||||
|
||||
// No Bootstrap collapse JS hooks anywhere in the shell markup.
|
||||
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
|
||||
Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,17 +124,32 @@ public static class ZbTelemetryExtensions
|
||||
/// Used internally by AddZbTelemetry. Exposed for tests and custom pipelines.
|
||||
public static class ZbResource
|
||||
{
|
||||
/// Deterministic, process-stable service instance identifier, formatted as
|
||||
/// "MachineName:ProcessId". Populates OTel Resource service.instance.id on every signal
|
||||
/// (metrics, traces, logs). The OTel SDK's random-GUID default is disabled in favour of this
|
||||
/// value so all signals from one process share one restart-stable instance id, enabling
|
||||
/// cross-signal correlation. Always present (not optional).
|
||||
public static string InstanceId { get; }
|
||||
|
||||
/// Returns a ResourceBuilder pre-populated with service.name, service.namespace,
|
||||
/// service.version, site.id, node.role, and host.name (always Environment.MachineName).
|
||||
/// Attributes with null values are omitted from the Resource.
|
||||
/// service.version, service.instance.id (always — see InstanceId), site.id, node.role, and
|
||||
/// host.name (always Environment.MachineName). Attributes with null/empty values are omitted.
|
||||
public static ResourceBuilder Build(ZbTelemetryOptions options);
|
||||
|
||||
/// The single source of truth for the shared Resource attribute set. Both the OTel SDK
|
||||
/// metrics/traces pipeline and the Serilog OTLP log sink derive their attributes from this one
|
||||
/// map, so logs cannot drift from metrics/traces. service.name/namespace/instance.id/host.name
|
||||
/// are always present; service.version/site.id/node.role are included only when the option is
|
||||
/// non-null/non-empty.
|
||||
public static IReadOnlyDictionary<string, object> BuildAttributes(ZbTelemetryOptions options);
|
||||
}
|
||||
|
||||
/// Endpoint extension for mounting the Prometheus /metrics scrape endpoint.
|
||||
public static class ZbMetricsEndpointExtensions
|
||||
{
|
||||
/// Mounts the Prometheus /metrics endpoint.
|
||||
/// Only valid when ZbTelemetryOptions.Exporter = ZbExporter.Prometheus (or both).
|
||||
/// Mounts the Prometheus /metrics endpoint. Valid under ANY ZbTelemetryOptions.Exporter value:
|
||||
/// AddZbTelemetry always wires the Prometheus exporter, and OTLP (ZbExporter.Otlp) is only an
|
||||
/// additive overlay — so /metrics serves scrape data even when Exporter = ZbExporter.Otlp.
|
||||
/// Call after app.UseRouting().
|
||||
public static IEndpointConventionBuilder MapZbMetrics(
|
||||
this IEndpointRouteBuilder endpoints);
|
||||
|
||||
@@ -104,6 +104,7 @@ joinable in any OTel-compatible backend.
|
||||
| `service.name` | string | Yes | Short lower-case app id: `otopcua`, `mxgateway`, `scadabridge` |
|
||||
| `service.namespace` | string | Yes | Always `"ZB.MOM.WW"` — do not override |
|
||||
| `service.version` | string | Recommended | Populate from `AssemblyInformationalVersion`; absent is better than wrong |
|
||||
| `service.instance.id` | string | Auto | Always `ZbResource.InstanceId` = deterministic `MachineName:ProcessId`. The OTel SDK random-GUID default is disabled so every signal from one process shares one restart-stable instance id (cross-signal correlation); never override |
|
||||
| `site.id` | string | Recommended | Physical or logical site identifier; omit for single-site deployments |
|
||||
| `node.role` | string | Recommended | Node function: `"central"`, `"site"`, `"hub"`, `"standalone"` |
|
||||
| `host.name` | string | Auto | Always `Environment.MachineName`; never override |
|
||||
|
||||
@@ -68,6 +68,7 @@ The OTel `Resource` attached to all three signals is built from `ZbTelemetryOpti
|
||||
| `service.name` | `ServiceName` | Required. Lower-case short identifier (`otopcua`, `mxgateway`, `scadabridge`) |
|
||||
| `service.namespace` | `ServiceNamespace` | Default `"ZB.MOM.WW"` — constant across the fleet |
|
||||
| `service.version` | `ServiceVersion` | Optional; recommend populating from `AssemblyInformationalVersion` |
|
||||
| `service.instance.id` | _(auto)_ | Always populated from `ZbResource.InstanceId` — a deterministic `MachineName:ProcessId`. The OTel SDK's random-GUID default is disabled so every signal from the same process carries an identical, restart-stable instance id for cross-signal correlation |
|
||||
| `site.id` | `SiteId` | Optional; identifies the physical/logical site |
|
||||
| `node.role` | `NodeRole` | Optional; e.g. `"central"`, `"site"`, `"hub"` |
|
||||
| `host.name` | _(auto)_ | Always populated from `Environment.MachineName` |
|
||||
|
||||
Reference in New Issue
Block a user