Add XML documentation across gateway, worker, and .NET client

This commit is contained in:
Joseph Doherty
2026-04-30 11:49:58 -04:00
parent 4731ab535c
commit eed1e88a37
269 changed files with 4555 additions and 13 deletions
@@ -4,6 +4,7 @@ namespace MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests
{
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task TestConnection_AgainstZb_Succeeds()
@@ -15,6 +16,7 @@ public sealed class GalaxyRepositoryLiveTests
Assert.True(ok, "TestConnectionAsync should return true against the ZB database.");
}
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
@@ -26,6 +28,7 @@ public sealed class GalaxyRepositoryLiveTests
Assert.NotNull(lastDeploy);
}
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
@@ -43,6 +46,7 @@ public sealed class GalaxyRepositoryLiveTests
});
}
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
@@ -1,10 +1,14 @@
namespace MxGateway.IntegrationTests.Galaxy;
/// <summary>Fact attribute that skips tests unless live Galaxy Repository tests are explicitly enabled.</summary>
public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
{
/// <summary>Environment variable name to enable live Galaxy Repository tests.</summary>
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS";
/// <summary>Environment variable name for the Galaxy Repository connection string.</summary>
public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN";
/// <summary>Initializes a new instance of the LiveGalaxyRepositoryFactAttribute class.</summary>
public LiveGalaxyRepositoryFactAttribute()
{
if (!Enabled)
@@ -13,12 +17,14 @@ public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
}
}
/// <summary>Gets a value indicating whether live Galaxy Repository tests are enabled.</summary>
public static bool Enabled =>
string.Equals(
Environment.GetEnvironmentVariable(EnableVariableName),
"1",
StringComparison.Ordinal);
/// <summary>Gets the Galaxy Repository connection string from environment or default.</summary>
public static string ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariableName)
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
@@ -8,27 +8,33 @@ public static class IntegrationTestEnvironment
public const string LiveMxAccessClientNameVariableName = "MXGATEWAY_LIVE_MXACCESS_CLIENT_NAME";
public const string LiveMxAccessEventTimeoutSecondsVariableName = "MXGATEWAY_LIVE_MXACCESS_EVENT_TIMEOUT_SECONDS";
/// <summary>Gets whether live MXAccess tests are enabled.</summary>
public static bool LiveMxAccessTestsEnabled =>
string.Equals(
Environment.GetEnvironmentVariable(LiveMxAccessVariableName),
"1",
StringComparison.Ordinal);
/// <summary>Gets the MXAccess item name for live tests.</summary>
public static string LiveMxAccessItem =>
GetOptionalEnvironmentVariable(
LiveMxAccessItemVariableName,
"TestChildObject.TestInt");
/// <summary>Gets the client name for live tests.</summary>
public static string LiveMxAccessClientName =>
GetOptionalEnvironmentVariable(
LiveMxAccessClientNameVariableName,
"MxGateway.IntegrationTests");
/// <summary>Gets the timeout for waiting on events in live tests.</summary>
public static TimeSpan LiveMxAccessEventTimeout =>
TimeSpan.FromSeconds(GetPositiveIntegerEnvironmentVariable(
LiveMxAccessEventTimeoutSecondsVariableName,
defaultValue: 15));
/// <summary>Resolves the path to the worker executable for live tests.</summary>
/// <returns>Path to MxGateway.Worker.exe.</returns>
public static string ResolveLiveMxAccessWorkerExecutablePath()
{
string? configuredPath = Environment.GetEnvironmentVariable(LiveMxAccessWorkerExecutableVariableName);
@@ -74,6 +80,9 @@ public static class IntegrationTestEnvironment
return defaultValue;
}
/// <summary>Resolves the root directory of the repository by searching for .git and src directories.</summary>
/// <param name="startDirectory">Starting directory to search from.</param>
/// <returns>The repository root path, or the start directory if not found.</returns>
internal static string ResolveRepositoryRoot(string startDirectory)
{
DirectoryInfo? directory = new(startDirectory);
@@ -2,6 +2,7 @@ namespace MxGateway.IntegrationTests;
public sealed class IntegrationTestEnvironmentTests
{
/// <summary>Verifies that live MXAccess tests use correct environment variable name.</summary>
[Fact]
public void LiveMxAccessTests_AreOptInByEnvironmentVariable()
{
@@ -10,6 +11,7 @@ public sealed class IntegrationTestEnvironmentTests
IntegrationTestEnvironment.LiveMxAccessVariableName);
}
/// <summary>Verifies that worker executable uses correct environment variable name.</summary>
[Fact]
public void LiveMxAccessWorkerExecutable_UsesDocumentedEnvironmentVariable()
{
@@ -18,6 +20,7 @@ public sealed class IntegrationTestEnvironmentTests
IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName);
}
/// <summary>Verifies that repository root resolution accepts git worktree files.</summary>
[Fact]
public void ResolveRepositoryRoot_AcceptsGitWorktreeFile()
{
@@ -1,7 +1,9 @@
namespace MxGateway.IntegrationTests;
/// <summary>Marks an xUnit test as requiring installed MXAccess COM and live provider state.</summary>
public sealed class LiveMxAccessFactAttribute : FactAttribute
{
/// <summary>Initializes the attribute, skipping the test unless the integration test environment variable is set.</summary>
public LiveMxAccessFactAttribute()
{
if (!IntegrationTestEnvironment.LiveMxAccessTestsEnabled)
@@ -21,6 +21,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(15);
private static readonly TimeSpan StreamShutdownTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
/// </summary>
[LiveMxAccessFact]
[Trait("Category", "LiveMxAccess")]
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
@@ -208,12 +211,21 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
$"Event value_type={dataChange.Value?.DataType} raw_status={dataChange.RawStatus}");
}
/// <summary>
/// Test fixture that assembles the gateway service with a worker process factory for live MXAccess testing.
/// </summary>
private sealed class GatewayServiceFixture : IAsyncDisposable
{
private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new();
private readonly ILoggerFactory _loggerFactory;
/// <summary>
/// Initializes the fixture with worker executable path, factory, and test output helper.
/// </summary>
/// <param name="workerExecutablePath">Path to the worker process executable.</param>
/// <param name="processFactory">Factory for creating worker processes.</param>
/// <param name="output">Test output helper for logging.</param>
public GatewayServiceFixture(
string workerExecutablePath,
IWorkerProcessFactory processFactory,
@@ -255,8 +267,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
_loggerFactory.CreateLogger<MxAccessGatewayService>());
}
/// <summary>
/// The assembled gateway service instance.
/// </summary>
public MxAccessGatewayService Service { get; }
/// <summary>
/// Disposes the fixture resources and closes all sessions.
/// </summary>
public async ValueTask DisposeAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -295,12 +313,18 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Gathers messages written to a server stream for test inspection.
/// </summary>
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
{
private readonly object syncRoot = new();
private readonly TaskCompletionSource<T> firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<T> messages = [];
/// <summary>
/// All messages that have been written to the stream.
/// </summary>
public IReadOnlyList<T> Messages
{
get
@@ -312,8 +336,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Inherited write options.
/// </summary>
public WriteOptions? WriteOptions { get; set; }
/// <summary>
/// Records the message and completes the first-message task.
/// </summary>
/// <param name="message">The message to write.</param>
public Task WriteAsync(T message)
{
lock (syncRoot)
@@ -325,12 +356,20 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return Task.CompletedTask;
}
/// <summary>
/// Waits for the first message up to the specified timeout.
/// </summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>The first message written to the stream.</returns>
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
{
return await firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
}
}
/// <summary>
/// Mock server call context for testing gRPC calls.
/// </summary>
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
{
private readonly Metadata requestHeaders = [];
@@ -339,43 +378,56 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
private Status status;
private WriteOptions? writeOptions;
/// <inheritdoc />
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
/// <inheritdoc />
protected override string HostCore => "localhost";
/// <inheritdoc />
protected override string PeerCore => "ipv4:127.0.0.1:5000";
/// <inheritdoc />
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
/// <inheritdoc />
protected override Metadata RequestHeadersCore => requestHeaders;
/// <inheritdoc />
protected override CancellationToken CancellationTokenCore => cancellationToken;
/// <inheritdoc />
protected override Metadata ResponseTrailersCore => responseTrailers;
/// <inheritdoc />
protected override Status StatusCore
{
get => status;
set => status = value;
}
/// <inheritdoc />
protected override WriteOptions? WriteOptionsCore
{
get => writeOptions;
set => writeOptions = value;
}
/// <inheritdoc />
protected override AuthContext AuthContextCore { get; } = new(
string.Empty,
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
/// <inheritdoc />
protected override IDictionary<object, object> UserStateCore => userState;
/// <inheritdoc />
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
{
return Task.CompletedTask;
}
/// <inheritdoc />
protected override ContextPropagationToken CreatePropagationTokenCore(
ContextPropagationOptions? options)
{
@@ -383,10 +435,14 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Factory that launches worker processes and records their outputs for testing.
/// </summary>
private sealed class TestWorkerProcessFactory(ITestOutputHelper output) : IWorkerProcessFactory
{
private readonly ConcurrentBag<TestWorkerProcess> processes = [];
/// <inheritdoc />
public IWorkerProcess Start(ProcessStartInfo startInfo)
{
startInfo.RedirectStandardError = true;
@@ -418,6 +474,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
return workerProcess;
}
/// <inheritdoc />
public async Task WaitForProcessesAsync(TimeSpan timeout)
{
foreach (TestWorkerProcess process in processes)
@@ -445,57 +502,77 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// Adapter wrapping a System.Diagnostics.Process as IWorkerProcess for testing.
/// </summary>
private sealed class TestWorkerProcess(Process process) : IWorkerProcess
{
/// <inheritdoc />
public int Id => process.Id;
/// <inheritdoc />
public bool HasExited => process.HasExited;
/// <inheritdoc />
public int? ExitCode => process.HasExited ? process.ExitCode : null;
/// <inheritdoc />
public async ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
process.Kill(entireProcessTree);
}
/// <inheritdoc />
public void Dispose()
{
process.Dispose();
}
}
/// <summary>
/// Logger provider that writes all output to the test output helper.
/// </summary>
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
{
/// <inheritdoc />
public ILogger CreateLogger(string categoryName)
{
return new TestOutputLogger(output, categoryName);
}
/// <inheritdoc />
public void Dispose()
{
}
}
/// <summary>
/// Logger that writes messages to the test output helper.
/// </summary>
private sealed class TestOutputLogger(
ITestOutputHelper output,
string categoryName) : ILogger
{
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= LogLevel.Information;
}
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -2,11 +2,15 @@ namespace MxGateway.Server.Configuration;
public sealed class AuthenticationOptions
{
/// <summary>Gets the authentication mode.</summary>
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
/// <summary>Gets the SQLite database path for authentication credentials.</summary>
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
/// <summary>Gets the secret manager name for API key pepper.</summary>
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
/// <summary>Gets whether database migrations should run on startup.</summary>
public bool RunMigrationsOnStartup { get; init; } = true;
}
@@ -2,19 +2,27 @@ namespace MxGateway.Server.Configuration;
public sealed class DashboardOptions
{
/// <summary>Gets whether the dashboard is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the dashboard URL path base.</summary>
public string PathBase { get; init; } = "/dashboard";
/// <summary>Gets whether dashboard access requires admin scope.</summary>
public bool RequireAdminScope { get; init; } = true;
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true;
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
/// <summary>Gets the maximum number of recent faults to display.</summary>
public int RecentFaultLimit { get; init; } = 100;
/// <summary>Gets the maximum number of recent sessions to display.</summary>
public int RecentSessionLimit { get; init; } = 200;
/// <summary>Gets whether to show full tag values in the dashboard.</summary>
public bool ShowTagValues { get; init; }
}
@@ -2,7 +2,13 @@ namespace MxGateway.Server.Configuration;
public sealed class EventOptions
{
/// <summary>
/// Gets the event queue capacity.
/// </summary>
public int QueueCapacity { get; init; } = 10_000;
/// <summary>
/// Gets the backpressure policy for event queue overflow.
/// </summary>
public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast;
}
@@ -2,10 +2,13 @@ using Microsoft.Extensions.Options;
namespace MxGateway.Server.Configuration;
/// <summary>Provides the effective gateway configuration with sensitive values redacted.</summary>
public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider
{
/// <summary>Marker string for redacted sensitive configuration values.</summary>
public const string RedactedValue = "[redacted]";
/// <inheritdoc />
public EffectiveGatewayConfiguration GetEffectiveConfiguration()
{
GatewayOptions value = options.Value;
@@ -4,6 +4,9 @@ namespace MxGateway.Server.Configuration;
public static class GatewayConfigurationServiceCollectionExtensions
{
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
{
services
@@ -4,15 +4,33 @@ public sealed class GatewayOptions
{
public const string SectionName = "MxGateway";
/// <summary>
/// Gets authentication configuration options.
/// </summary>
public AuthenticationOptions Authentication { get; init; } = new();
/// <summary>
/// Gets worker process configuration options.
/// </summary>
public WorkerOptions Worker { get; init; } = new();
/// <summary>
/// Gets session management configuration options.
/// </summary>
public SessionOptions Sessions { get; init; } = new();
/// <summary>
/// Gets event stream configuration options.
/// </summary>
public EventOptions Events { get; init; } = new();
/// <summary>
/// Gets dashboard configuration options.
/// </summary>
public DashboardOptions Dashboard { get; init; } = new();
/// <summary>
/// Gets protocol configuration options.
/// </summary>
public ProtocolOptions Protocol { get; init; } = new();
}
@@ -8,6 +8,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
private const int MinimumMaxMessageBytes = 1024;
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
/// <summary>
/// Validates gateway configuration options.
/// </summary>
/// <param name="name">Options name.</param>
/// <param name="options">Gateway options to validate.</param>
/// <returns>Validation result.</returns>
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
{
List<string> failures = [];
@@ -1,6 +1,12 @@
namespace MxGateway.Server.Configuration;
/// <summary>
/// Provides the effective gateway configuration, applying defaults and validations.
/// </summary>
public interface IGatewayConfigurationProvider
{
/// <summary>
/// Returns the validated and effective gateway configuration.
/// </summary>
EffectiveGatewayConfiguration GetEffectiveConfiguration();
}
@@ -2,7 +2,13 @@ using MxGateway.Contracts;
namespace MxGateway.Server.Configuration;
/// <summary>
/// Configuration options for the worker protocol version.
/// </summary>
public sealed class ProtocolOptions
{
/// <summary>
/// Gets or sets the worker protocol version.
/// </summary>
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
}
@@ -2,11 +2,23 @@ namespace MxGateway.Server.Configuration;
public sealed class SessionOptions
{
/// <summary>
/// Gets the default command timeout in seconds.
/// </summary>
public int DefaultCommandTimeoutSeconds { get; init; } = 30;
/// <summary>
/// Gets the maximum number of concurrent sessions.
/// </summary>
public int MaxSessions { get; init; } = 64;
/// <summary>
/// Gets the maximum number of pending commands per session.
/// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128;
/// <summary>
/// Gets a value indicating whether multiple event subscribers are allowed per session.
/// </summary>
public bool AllowMultipleEventSubscribers { get; init; }
}
@@ -2,26 +2,37 @@ namespace MxGateway.Server.Configuration;
public sealed class WorkerOptions
{
/// <summary>The path to the worker executable.</summary>
public string ExecutablePath { get; init; } =
@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe";
/// <summary>The working directory for the worker process, or null to inherit.</summary>
public string? WorkingDirectory { get; init; }
/// <summary>The required processor architecture for the worker.</summary>
public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86;
/// <summary>The maximum time in seconds for the worker to start.</summary>
public int StartupTimeoutSeconds { get; init; } = 30;
/// <summary>The number of retry attempts for the startup probe.</summary>
public int StartupProbeRetryAttempts { get; init; } = 3;
/// <summary>The delay in milliseconds between startup probe retries.</summary>
public int StartupProbeRetryDelayMilliseconds { get; init; } = 250;
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
public int ShutdownTimeoutSeconds { get; init; } = 10;
/// <summary>The interval in seconds for worker heartbeats.</summary>
public int HeartbeatIntervalSeconds { get; init; } = 5;
/// <summary>The grace period in seconds after a heartbeat before considering the worker unresponsive.</summary>
public int HeartbeatGraceSeconds { get; init; } = 15;
/// <summary>The maximum message size in bytes for IPC communication.</summary>
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
}
@@ -2,6 +2,11 @@ namespace MxGateway.Server.Dashboard.Components;
public static class DashboardDisplay
{
/// <summary>
/// Formats a nullable date and time value for display.
/// </summary>
/// <param name="value">The date and time to format.</param>
/// <returns>Formatted date and time string or "-" if null.</returns>
public static string DateTime(DateTimeOffset? value)
{
return value.HasValue
@@ -9,6 +14,11 @@ public static class DashboardDisplay
: "-";
}
/// <summary>
/// Formats a time span duration for display.
/// </summary>
/// <param name="value">The duration to format.</param>
/// <returns>Formatted duration string.</returns>
public static string Duration(TimeSpan value)
{
return value.TotalDays >= 1
@@ -16,16 +26,33 @@ public static class DashboardDisplay
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Formats a nullable text value for display.
/// </summary>
/// <param name="value">The text to format.</param>
/// <returns>Formatted text or "-" if null or empty.</returns>
public static string Text(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "-" : value;
}
/// <summary>
/// Formats a long count value for display with thousands separator.
/// </summary>
/// <param name="value">The count to format.</param>
/// <returns>Formatted count string.</returns>
public static string Count(long value)
{
return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
}
/// <summary>
/// Retrieves a metric value from a snapshot by name and optional dimension.
/// </summary>
/// <param name="snapshot">Dashboard snapshot.</param>
/// <param name="name">Metric name.</param>
/// <param name="dimension">Optional metric dimension.</param>
/// <returns>Metric value or zero if not found.</returns>
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
{
return snapshot.Metrics.FirstOrDefault(metric =>
@@ -2,21 +2,32 @@ using Microsoft.AspNetCore.Components;
namespace MxGateway.Server.Dashboard.Components;
/// <summary>
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
/// </summary>
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
{
private readonly CancellationTokenSource _disposeCancellation = new();
private Task? _watchTask;
/// <summary>
/// Service that provides gateway metric snapshots.
/// </summary>
[Inject]
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
/// <summary>
/// The most recent gateway metric snapshot, updated as it changes.
/// </summary>
protected DashboardSnapshot? Snapshot { get; private set; }
/// <inheritdoc />
protected override void OnInitialized()
{
_watchTask = WatchSnapshotsAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
@@ -29,6 +40,9 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
GC.SuppressFinalize(this);
}
/// <summary>
/// Watches snapshot changes and triggers component refresh.
/// </summary>
private async Task WatchSnapshotsAsync()
{
try
@@ -2,16 +2,36 @@ using System.Security.Claims;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Result of a dashboard authentication attempt.
/// </summary>
public sealed record DashboardAuthenticationResult(
/// <summary>
/// Whether authentication succeeded.
/// </summary>
bool Succeeded,
/// <summary>
/// The authenticated principal if successful; otherwise null.
/// </summary>
ClaimsPrincipal? Principal,
/// <summary>
/// The failure message if authentication failed; otherwise null.
/// </summary>
string? FailureMessage)
{
/// <summary>
/// Creates a successful authentication result.
/// </summary>
/// <param name="principal">Authenticated principal.</param>
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
}
/// <summary>
/// Creates a failed authentication result.
/// </summary>
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
@@ -12,6 +12,7 @@ public sealed class DashboardAuthenticator(
{
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
/// <inheritdoc />
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken)
@@ -10,6 +10,7 @@ public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
{
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DashboardAuthorizationRequirement requirement)
@@ -7,8 +7,12 @@ using MxGateway.Server.Dashboard.Components;
namespace MxGateway.Server.Dashboard;
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
public static class DashboardEndpointRouteBuilderExtensions
{
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <returns>The route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
{
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
@@ -8,6 +8,7 @@ namespace MxGateway.Server.Dashboard;
/// per-category breakdowns are computed here rather than stored on the cache so the
/// Galaxy namespace stays free of dashboard-presentation concepts.
/// </summary>
/// <summary>Projects Galaxy Repository cache entries to dashboard presentation format.</summary>
internal static class DashboardGalaxyProjector
{
private const int TopTemplatesLimit = 10;
@@ -25,6 +26,9 @@ internal static class DashboardGalaxyProjector
[26] = "OPCClient",
};
/// <summary>Projects a Galaxy Repository cache entry to a dashboard summary.</summary>
/// <param name="entry">Galaxy cache entry to project.</param>
/// <returns>Dashboard-formatted Galaxy summary.</returns>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
DashboardGalaxyStatus status = entry.Status switch
@@ -19,6 +19,7 @@ public sealed record DashboardGalaxySummary(
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
{
/// <summary>Gets the unknown Galaxy status placeholder.</summary>
public static DashboardGalaxySummary Unknown { get; } = new(
DashboardGalaxyStatus.Unknown,
LastQueriedAt: null,
@@ -15,6 +15,11 @@ internal static class DashboardRedactor
"token",
];
/// <summary>
/// Redacts sensitive content from a value for dashboard display.
/// </summary>
/// <param name="value">Value to redact.</param>
/// <returns>Redacted value or original value if not sensitive.</returns>
public static string? Redact(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -5,8 +5,15 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Extension methods for configuring the gateway dashboard services.
/// </summary>
public static class DashboardServiceCollectionExtensions
{
/// <summary>
/// Registers all dashboard services, authentication, and Razor components.
/// </summary>
/// <param name="services">Service collection to register services.</param>
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
@@ -22,6 +22,13 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly int _recentFaultLimit;
private readonly int _recentSessionLimit;
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</param>
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
/// <param name="options">Gateway configuration options.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
@@ -43,6 +50,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
}
/// <summary>
/// Gets a current dashboard snapshot of gateway state.
/// </summary>
/// <returns>Dashboard snapshot.</returns>
public DashboardSnapshot GetSnapshot()
{
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
@@ -73,6 +84,11 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
/// <summary>
/// Watches dashboard snapshots at regular intervals asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of dashboard snapshots.</returns>
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -1,7 +1,15 @@
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates dashboard access with API keys.
/// </summary>
public interface IDashboardAuthenticator
{
/// <summary>
/// Authenticates the dashboard session with an API key.
/// </summary>
/// <param name="apiKey">The API key to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken);
@@ -1,8 +1,18 @@
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Provides snapshots of the dashboard state for UI updates.
/// </summary>
public interface IDashboardSnapshotService
{
/// <summary>
/// Gets the current dashboard snapshot.
/// </summary>
DashboardSnapshot GetSnapshot();
/// <summary>
/// Watches for changes to the dashboard state as an async enumerable.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
}
@@ -1,7 +1,11 @@
namespace MxGateway.Server.Diagnostics;
/// <summary>
/// Redacts sensitive information from log entries.
/// </summary>
public static class GatewayLogRedactor
{
/// <summary>Placeholder for redacted values.</summary>
public const string RedactedValue = "[redacted]";
private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase)
@@ -11,12 +15,20 @@ public static class GatewayLogRedactor
"WriteSecured2"
};
/// <summary>
/// Determines whether a command method bears credentials.
/// </summary>
/// <param name="commandMethod">The command method name to check.</param>
public static bool IsCredentialBearingCommand(string? commandMethod)
{
return commandMethod is not null
&& SensitiveCommandMethods.Contains(commandMethod);
}
/// <summary>
/// Redacts the API key secret portion of a Bearer authorization header.
/// </summary>
/// <param name="authorizationHeader">The authorization header value to redact.</param>
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
@@ -46,6 +58,10 @@ public static class GatewayLogRedactor
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
}
/// <summary>
/// Redacts the client identity if it contains an API key.
/// </summary>
/// <param name="clientIdentity">The client identity string to redact.</param>
public static string? RedactClientIdentity(string? clientIdentity)
{
if (string.IsNullOrWhiteSpace(clientIdentity))
@@ -58,6 +74,12 @@ public static class GatewayLogRedactor
: clientIdentity;
}
/// <summary>
/// Redacts a command value if it contains credentials or value logging is disabled.
/// </summary>
/// <param name="commandMethod">The command method name to check for credentials.</param>
/// <param name="value">The command value to redact.</param>
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
public static object? RedactCommandValue(
string? commandMethod,
object? value,
@@ -7,6 +7,7 @@ public sealed record GatewayLogScope(
string? CommandMethod = null,
string? ClientIdentity = null)
{
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
public IReadOnlyDictionary<string, object?> ToDictionary()
{
Dictionary<string, object?> values = [];
@@ -4,6 +4,10 @@ namespace MxGateway.Server.Diagnostics;
public static class GatewayLoggerExtensions
{
/// <summary>Begins a gateway log scope with the specified scope properties.</summary>
/// <param name="logger">Logger used for diagnostic output.</param>
/// <param name="scope">Scope properties to apply.</param>
/// <returns>A disposable that ends the scope when disposed.</returns>
public static IDisposable? BeginGatewayScope(
this ILogger logger,
GatewayLogScope scope)
@@ -2,13 +2,23 @@ using Microsoft.Extensions.Primitives;
namespace MxGateway.Server.Diagnostics;
/// <summary>Middleware extensions for structured gateway request logging with correlation context.</summary>
public static class GatewayRequestLoggingMiddlewareExtensions
{
/// <summary>Header name for the session ID.</summary>
public const string SessionIdHeaderName = "x-session-id";
/// <summary>Header name for the worker process ID.</summary>
public const string WorkerProcessIdHeaderName = "x-worker-process-id";
/// <summary>Header name for the correlation ID.</summary>
public const string CorrelationIdHeaderName = "x-correlation-id";
/// <summary>Header name for the command method name.</summary>
public const string CommandMethodHeaderName = "x-command-method";
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
/// <param name="app">Application builder.</param>
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
@@ -10,6 +10,9 @@ namespace MxGateway.Server.Galaxy;
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
/// event is dropped — clients use the sequence field to detect gaps.
/// </summary>
/// <summary>
/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels.
/// </summary>
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
{
private const int SubscriberQueueCapacity = 16;
@@ -17,8 +20,12 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
private GalaxyDeployEventInfo? _latest;
/// <summary>
/// The most recent deploy event, or null if none has been published.
/// </summary>
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
/// <inheritdoc />
public void Publish(GalaxyDeployEventInfo info)
{
ArgumentNullException.ThrowIfNull(info);
@@ -33,6 +40,7 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
}
}
/// <inheritdoc />
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -25,6 +25,11 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
/// <param name="notifier">Galaxy deploy event notifier.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public GalaxyHierarchyCache(
GalaxyRepository repository,
IGalaxyDeployNotifier notifier,
@@ -37,6 +42,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
_logger = logger;
}
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
public GalaxyHierarchyCacheEntry Current
{
get
@@ -47,6 +53,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
}
}
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the refresh operation.</returns>
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -60,6 +69,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
}
}
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the wait operation.</returns>
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
{
return _firstLoad.Task.WaitAsync(cancellationToken);
@@ -23,6 +23,7 @@ public sealed record GalaxyHierarchyCacheEntry(
int HistorizedAttributeCount,
int AlarmAttributeCount)
{
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
Status: GalaxyCacheStatus.Unknown,
Sequence: 0,
@@ -39,5 +40,6 @@ public sealed record GalaxyHierarchyCacheEntry(
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0);
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
}
@@ -4,11 +4,7 @@ using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Periodically refreshes <see cref="IGalaxyHierarchyCache"/> off the request path. The
/// interval comes from <see cref="GalaxyRepositoryOptions.DashboardRefreshIntervalSeconds"/>;
/// each tick is cheap when the deploy timestamp is unchanged.
/// </summary>
/// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
public sealed class GalaxyHierarchyRefreshService(
IGalaxyHierarchyCache cache,
IOptions<GalaxyRepositoryOptions> options,
@@ -17,6 +13,7 @@ public sealed class GalaxyHierarchyRefreshService(
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
@@ -6,30 +6,70 @@ namespace MxGateway.Server.Galaxy;
/// </summary>
public sealed class GalaxyHierarchyRow
{
/// <summary>Gets the Galaxy object identifier.</summary>
public int GobjectId { get; init; }
/// <summary>Gets the tag name.</summary>
public string TagName { get; init; } = string.Empty;
/// <summary>Gets the contained name.</summary>
public string ContainedName { get; init; } = string.Empty;
/// <summary>Gets the browse name.</summary>
public string BrowseName { get; init; } = string.Empty;
/// <summary>Gets the parent Galaxy object identifier.</summary>
public int ParentGobjectId { get; init; }
/// <summary>Gets a value indicating whether this is an area.</summary>
public bool IsArea { get; init; }
/// <summary>Gets the category identifier.</summary>
public int CategoryId { get; init; }
/// <summary>Gets the Galaxy object identifier of the host.</summary>
public int HostedByGobjectId { get; init; }
/// <summary>Gets the template derivation chain.</summary>
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
}
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
public sealed class GalaxyAttributeRow
{
/// <summary>Gets the Galaxy object identifier.</summary>
public int GobjectId { get; init; }
/// <summary>Gets the tag name.</summary>
public string TagName { get; init; } = string.Empty;
/// <summary>Gets the attribute name.</summary>
public string AttributeName { get; init; } = string.Empty;
/// <summary>Gets the full tag reference.</summary>
public string FullTagReference { get; init; } = string.Empty;
/// <summary>Gets the MXAccess data type code.</summary>
public int MxDataType { get; init; }
/// <summary>Gets the data type name.</summary>
public string? DataTypeName { get; init; }
/// <summary>Gets a value indicating whether this is an array.</summary>
public bool IsArray { get; init; }
/// <summary>Gets the array dimension, if applicable.</summary>
public int? ArrayDimension { get; init; }
/// <summary>Gets the MXAccess attribute category code.</summary>
public int MxAttributeCategory { get; init; }
/// <summary>Gets the security classification code.</summary>
public int SecurityClassification { get; init; }
/// <summary>Gets a value indicating whether this is historized.</summary>
public bool IsHistorized { get; init; }
/// <summary>Gets a value indicating whether this is an alarm.</summary>
public bool IsAlarm { get; init; }
}
@@ -10,6 +10,8 @@ namespace MxGateway.Server.Galaxy;
/// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
{
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
@@ -24,6 +26,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
catch (InvalidOperationException) { return false; }
}
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using SqlConnection conn = new(options.ConnectionString);
@@ -34,6 +38,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
return result is DateTime dt ? dt : null;
}
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
List<GalaxyHierarchyRow> rows = new();
@@ -70,6 +76,8 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
return rows;
}
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
/// <param name="ct">Token to cancel the asynchronous operation.</param>
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
List<GalaxyAttributeRow> rows = new();
@@ -8,9 +8,11 @@ public sealed class GalaxyRepositoryOptions
{
public const string SectionName = "MxGateway:Galaxy";
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
public string ConnectionString { get; init; } =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
public int CommandTimeoutSeconds { get; init; } = 60;
/// <summary>
@@ -4,6 +4,9 @@ namespace MxGateway.Server.Galaxy;
public static class GalaxyRepositoryServiceCollectionExtensions
{
/// <summary>Registers Galaxy Repository services in the dependency injection container.</summary>
/// <param name="services">The service collection.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
{
services
@@ -1,18 +1,17 @@
namespace MxGateway.Server.Galaxy;
/// <summary>Publishes Galaxy repository deploy events to subscribers.</summary>
public interface IGalaxyDeployNotifier
{
/// <summary>The most recently published event, or <c>null</c> if no event has fired yet.</summary>
/// <summary>The most recently published event, or null if no event has fired yet.</summary>
GalaxyDeployEventInfo? Latest { get; }
/// <summary>Publishes a deploy event to all current subscribers and stores it as <see cref="Latest"/>.</summary>
/// <summary>Publishes a deploy event to all current subscribers and stores it as Latest.</summary>
/// <param name="info">The deploy event to publish.</param>
void Publish(GalaxyDeployEventInfo info);
/// <summary>
/// Subscribe to deploy events. The async sequence yields events as they fire. If
/// <see cref="Latest"/> is set, it is yielded first so subscribers can bootstrap their
/// local cache without waiting for the next deploy. Pass a cancellation token to
/// unsubscribe.
/// </summary>
/// <summary>Subscribes to deploy events. The sequence yields the latest event first (if available) then streams new events as they fire.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Async enumerable of deploy events.</returns>
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
}
@@ -1,5 +1,6 @@
namespace MxGateway.Server.Galaxy;
/// <summary>Cache for Galaxy Repository hierarchy data.</summary>
public interface IGalaxyHierarchyCache
{
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
@@ -11,6 +12,7 @@ public interface IGalaxyHierarchyCache
/// attributes rowsets when the deploy time has changed since the last successful
/// refresh.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task RefreshAsync(CancellationToken cancellationToken);
/// <summary>
@@ -18,5 +20,6 @@ public interface IGalaxyHierarchyCache
/// gRPC handlers that want to serve from cache without returning Unavailable on the
/// very first request after gateway start.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
}
@@ -13,10 +13,18 @@ using MxGateway.Server.Workers;
namespace MxGateway.Server;
/// <summary>
/// Configures and builds the gateway web application.
/// </summary>
public static class GatewayApplication
{
private const string StaticAssetsManifestFileName = "MxGateway.Server.staticwebassets.endpoints.json";
/// <summary>
/// Builds a configured web application with all gateway services and middleware.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application ready to run.</returns>
public static WebApplication Build(string[] args)
{
WebApplicationBuilder builder = CreateBuilder(args);
@@ -32,6 +40,11 @@ public static class GatewayApplication
return app;
}
/// <summary>
/// Creates a web application builder configured with gateway services.
/// </summary>
/// <param name="args">Command-line arguments passed to the application.</param>
/// <returns>A configured web application builder.</returns>
public static WebApplicationBuilder CreateBuilder(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions
@@ -112,6 +125,11 @@ public static class GatewayApplication
&& Directory.Exists(Path.Combine(path, "wwwroot"));
}
/// <summary>
/// Maps gateway endpoints including gRPC services, health checks, and the dashboard.
/// </summary>
/// <param name="endpoints">Endpoint route builder to map endpoints to.</param>
/// <returns>The same endpoint route builder for chaining.</returns>
public static IEndpointRouteBuilder MapGatewayEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
@@ -16,6 +16,12 @@ public sealed class EventStreamService(
GatewayMetrics metrics,
ILogger<EventStreamService> logger) : IEventStreamService
{
/// <summary>
/// Streams events from a session to the client asynchronously.
/// </summary>
/// <param name="request">Stream events request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of MX events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -10,6 +10,9 @@ namespace MxGateway.Server.Grpc;
/// </summary>
public static class GalaxyProtoMapper
{
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
public static IEnumerable<GalaxyObject> MapHierarchy(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
@@ -24,6 +27,9 @@ public static class GalaxyProtoMapper
}
}
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
public static GalaxyObject MapObject(
GalaxyHierarchyRow row,
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
@@ -52,6 +58,8 @@ public static class GalaxyProtoMapper
return obj;
}
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
/// <param name="row">Attribute row from Galaxy Repository.</param>
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
{
AttributeName = row.AttributeName,
@@ -22,6 +22,7 @@ public sealed class GalaxyRepositoryGrpcService(
{
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override async Task<TestConnectionReply> TestConnection(
TestConnectionRequest request,
ServerCallContext context)
@@ -30,6 +31,7 @@ public sealed class GalaxyRepositoryGrpcService(
return new TestConnectionReply { Ok = ok };
}
/// <inheritdoc />
public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
GetLastDeployTimeRequest request,
ServerCallContext context)
@@ -52,6 +54,7 @@ public sealed class GalaxyRepositoryGrpcService(
return reply;
}
/// <inheritdoc />
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
DiscoverHierarchyRequest request,
ServerCallContext context)
@@ -71,6 +74,7 @@ public sealed class GalaxyRepositoryGrpcService(
return entry.Reply;
}
/// <inheritdoc />
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
IServerStreamWriter<DeployEvent> responseStream,
@@ -2,8 +2,16 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc;
/// <summary>
/// Streams MXAccess events to gRPC clients.
/// </summary>
public interface IEventStreamService
{
/// <summary>
/// Streams events for the specified session to the caller.
/// </summary>
/// <param name="request">Request payload.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
@@ -9,6 +9,7 @@ using MxGateway.Server.Workers;
namespace MxGateway.Server.Grpc;
/// <summary>gRPC service implementation for MXAccess Gateway operations.</summary>
public sealed class MxAccessGatewayService(
ISessionManager sessionManager,
IGatewayRequestIdentityAccessor identityAccessor,
@@ -18,6 +19,7 @@ public sealed class MxAccessGatewayService(
GatewayMetrics metrics,
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
{
/// <inheritdoc />
public override async Task<OpenSessionReply> OpenSession(
OpenSessionRequest request,
ServerCallContext context)
@@ -56,6 +58,7 @@ public sealed class MxAccessGatewayService(
}
}
/// <inheritdoc />
public override async Task<CloseSessionReply> CloseSession(
CloseSessionRequest request,
ServerCallContext context)
@@ -80,6 +83,7 @@ public sealed class MxAccessGatewayService(
}
}
/// <inheritdoc />
public override async Task<MxCommandReply> Invoke(
MxCommandRequest request,
ServerCallContext context)
@@ -100,6 +104,7 @@ public sealed class MxAccessGatewayService(
}
}
/// <inheritdoc />
public override async Task StreamEvents(
StreamEventsRequest request,
IServerStreamWriter<MxEvent> responseStream,
@@ -3,15 +3,26 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Grpc;
/// <summary>
/// Maps between worker IPC types and gRPC contract types.
/// </summary>
public sealed class MxAccessGrpcMapper
{
private readonly TimeProvider _timeProvider;
/// <summary>
/// Initializes the mapper with an optional time provider.
/// </summary>
/// <param name="timeProvider">Time provider for timestamps; defaults to system time if null.</param>
public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
}
/// <summary>
/// Maps a gRPC MX command request to a worker command.
/// </summary>
/// <param name="request">Request payload.</param>
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -24,6 +35,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Maps a worker command reply to a gRPC MX command reply.
/// </summary>
/// <param name="reply">Worker command reply.</param>
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
{
ArgumentNullException.ThrowIfNull(reply);
@@ -39,6 +54,10 @@ public sealed class MxAccessGrpcMapper
return reply.Reply.Clone();
}
/// <summary>
/// Maps a worker event to a gRPC MX event.
/// </summary>
/// <param name="workerEvent">Worker event to map.</param>
public MxEvent MapEvent(WorkerEvent workerEvent)
{
ArgumentNullException.ThrowIfNull(workerEvent);
@@ -50,6 +69,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates an OK protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Ok(string message = "OK")
{
return new ProtocolStatus
@@ -59,6 +82,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates an InvalidRequest protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus InvalidRequest(string message)
{
return new ProtocolStatus
@@ -68,6 +95,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a SessionNotFound protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotFound(string message)
{
return new ProtocolStatus
@@ -77,6 +108,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a SessionNotReady protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus SessionNotReady(string message)
{
return new ProtocolStatus
@@ -86,6 +121,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a WorkerUnavailable protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus WorkerUnavailable(string message)
{
return new ProtocolStatus
@@ -95,6 +134,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a Timeout protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Timeout(string message)
{
return new ProtocolStatus
@@ -104,6 +147,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a Canceled protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus Canceled(string message)
{
return new ProtocolStatus
@@ -113,6 +160,10 @@ public sealed class MxAccessGrpcMapper
};
}
/// <summary>
/// Creates a ProtocolViolation protocol status.
/// </summary>
/// <param name="message">Status message.</param>
public static ProtocolStatus ProtocolViolation(string message)
{
return new ProtocolStatus
@@ -5,6 +5,8 @@ namespace MxGateway.Server.Grpc;
public sealed class MxAccessGrpcRequestValidator
{
/// <summary>Validates an open session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateOpenSession(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -15,18 +17,24 @@ public sealed class MxAccessGrpcRequestValidator
}
}
/// <summary>Validates a close session request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateCloseSession(CloseSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
/// <summary>Validates a stream events request.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateStreamEvents(StreamEventsRequest request)
{
ArgumentNullException.ThrowIfNull(request);
RequireSessionId(request.SessionId);
}
/// <summary>Validates an invoke request with command payload.</summary>
/// <param name="request">The request to validate.</param>
public void ValidateInvoke(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -49,6 +49,9 @@ public sealed class GatewayMetrics : IDisposable
private long _retryAttempts;
private bool _disposed;
/// <summary>
/// Initializes the gateway metrics with OpenTelemetry counters and histograms.
/// </summary>
public GatewayMetrics()
{
_meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString());
@@ -75,6 +78,9 @@ public sealed class GatewayMetrics : IDisposable
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
}
/// <summary>
/// Records that a session has been opened.
/// </summary>
public void SessionOpened()
{
lock (_syncRoot)
@@ -86,6 +92,9 @@ public sealed class GatewayMetrics : IDisposable
_sessionsOpenedCounter.Add(1);
}
/// <summary>
/// Records that a session has been closed.
/// </summary>
public void SessionClosed()
{
lock (_syncRoot)
@@ -101,6 +110,9 @@ public sealed class GatewayMetrics : IDisposable
_sessionsClosedCounter.Add(1);
}
/// <summary>
/// Records that a session has been removed from registry.
/// </summary>
public void SessionRemoved()
{
lock (_syncRoot)
@@ -112,6 +124,10 @@ public sealed class GatewayMetrics : IDisposable
}
}
/// <summary>
/// Records that a worker process has started and its startup latency.
/// </summary>
/// <param name="startupDuration">Duration elapsed while starting the worker.</param>
public void WorkerStarted(TimeSpan startupDuration)
{
lock (_syncRoot)
@@ -122,6 +138,10 @@ public sealed class GatewayMetrics : IDisposable
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds);
}
/// <summary>
/// Records that a worker process has stopped with the given reason.
/// </summary>
/// <param name="reason">Cause of the worker stopping.</param>
public void WorkerStopped(string reason)
{
lock (_syncRoot)
@@ -137,6 +157,10 @@ public sealed class GatewayMetrics : IDisposable
_workerExitsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
}
/// <summary>
/// Records that a worker process was killed with the given reason.
/// </summary>
/// <param name="reason">Cause of the worker termination.</param>
public void WorkerKilled(string reason)
{
lock (_syncRoot)
@@ -147,6 +171,10 @@ public sealed class GatewayMetrics : IDisposable
_workerKillsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
}
/// <summary>
/// Records that a command has started for the given method.
/// </summary>
/// <param name="method">Name of the command method.</param>
public void CommandStarted(string method)
{
lock (_syncRoot)
@@ -157,6 +185,11 @@ public sealed class GatewayMetrics : IDisposable
_commandsStartedCounter.Add(1, new KeyValuePair<string, object?>("method", method));
}
/// <summary>
/// Records that a command succeeded for the given method and duration.
/// </summary>
/// <param name="method">Name of the command method.</param>
/// <param name="duration">Elapsed time to complete the command.</param>
public void CommandSucceeded(string method, TimeSpan duration)
{
lock (_syncRoot)
@@ -169,6 +202,12 @@ public sealed class GatewayMetrics : IDisposable
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag);
}
/// <summary>
/// Records that a command failed for the given method, category, and duration.
/// </summary>
/// <param name="method">Name of the command method.</param>
/// <param name="category">Classification of the failure.</param>
/// <param name="duration">Elapsed time before command failed.</param>
public void CommandFailed(string method, string category, TimeSpan duration)
{
lock (_syncRoot)
@@ -183,6 +222,11 @@ public sealed class GatewayMetrics : IDisposable
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag);
}
/// <summary>
/// Records that an event was received for the given session and family.
/// </summary>
/// <param name="sessionId">Identifier of the session receiving the event.</param>
/// <param name="family">Event family classification.</param>
public void EventReceived(string sessionId, string family)
{
Interlocked.Increment(ref _eventsReceived);
@@ -194,6 +238,11 @@ public sealed class GatewayMetrics : IDisposable
new KeyValuePair<string, object?>("family", family));
}
/// <summary>
/// Records the latency of sending an event to a client stream.
/// </summary>
/// <param name="family">Event family name.</param>
/// <param name="duration">Time taken to send the event.</param>
public void RecordEventStreamSend(string family, TimeSpan duration)
{
_eventStreamSendLatencyHistogram.Record(
@@ -201,11 +250,19 @@ public sealed class GatewayMetrics : IDisposable
new KeyValuePair<string, object?>("family", family));
}
/// <summary>
/// Sets the worker event queue depth; delegates to SetWorkerEventQueueDepth.
/// </summary>
/// <param name="depth">Queue depth value.</param>
public void SetEventQueueDepth(int depth)
{
SetWorkerEventQueueDepth(depth);
}
/// <summary>
/// Sets the worker event queue depth to the given value.
/// </summary>
/// <param name="depth">Queue depth value.</param>
public void SetWorkerEventQueueDepth(int depth)
{
if (depth < 0)
@@ -219,6 +276,10 @@ public sealed class GatewayMetrics : IDisposable
}
}
/// <summary>
/// Adjusts the gRPC event stream queue depth by the given delta.
/// </summary>
/// <param name="delta">Amount to adjust the queue depth by.</param>
public void AdjustGrpcEventStreamQueueDepth(int delta)
{
lock (_syncRoot)
@@ -227,11 +288,19 @@ public sealed class GatewayMetrics : IDisposable
}
}
/// <summary>
/// Removes event counters for the given session.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
public void RemoveSessionEvents(string sessionId)
{
_eventsBySession.TryRemove(sessionId, out _);
}
/// <summary>
/// Records that a queue overflow occurred for the given queue name.
/// </summary>
/// <param name="queueName">Name of the queue that overflowed.</param>
public void QueueOverflow(string queueName)
{
lock (_syncRoot)
@@ -242,6 +311,10 @@ public sealed class GatewayMetrics : IDisposable
_queueOverflowsCounter.Add(1, new KeyValuePair<string, object?>("queue", queueName));
}
/// <summary>
/// Records that a fault occurred in the given category.
/// </summary>
/// <param name="category">Category of the fault.</param>
public void Fault(string category)
{
lock (_syncRoot)
@@ -252,6 +325,10 @@ public sealed class GatewayMetrics : IDisposable
_faultsCounter.Add(1, new KeyValuePair<string, object?>("category", category));
}
/// <summary>
/// Records that a heartbeat failed for the given session.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
public void HeartbeatFailed(string sessionId)
{
lock (_syncRoot)
@@ -262,6 +339,10 @@ public sealed class GatewayMetrics : IDisposable
_heartbeatFailuresCounter.Add(1, new KeyValuePair<string, object?>("session_id", sessionId));
}
/// <summary>
/// Records that an event stream was disconnected with the given reason.
/// </summary>
/// <param name="reason">Reason for the disconnection.</param>
public void StreamDisconnected(string reason)
{
lock (_syncRoot)
@@ -272,6 +353,10 @@ public sealed class GatewayMetrics : IDisposable
_streamDisconnectsCounter.Add(1, new KeyValuePair<string, object?>("reason", reason));
}
/// <summary>
/// Records that a retry was attempted in the given area.
/// </summary>
/// <param name="area">Area in which the retry was attempted.</param>
public void RetryAttempted(string area)
{
lock (_syncRoot)
@@ -283,6 +368,9 @@ public sealed class GatewayMetrics : IDisposable
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
}
/// <summary>
/// Returns a snapshot of all current metric values.
/// </summary>
public GatewayMetricsSnapshot GetSnapshot()
{
lock (_syncRoot)
@@ -312,6 +400,9 @@ public sealed class GatewayMetrics : IDisposable
}
}
/// <summary>
/// Disposes the underlying OpenTelemetry meter.
/// </summary>
public void Dispose()
{
if (_disposed)
@@ -2,6 +2,9 @@ using System.Text.Json;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Executes API key administration commands from the CLI.
/// </summary>
public sealed class ApiKeyAdminCliRunner(
IAuthStoreMigrator migrator,
IApiKeyAdminStore adminStore,
@@ -13,6 +16,12 @@ public sealed class ApiKeyAdminCliRunner(
WriteIndented = true
};
/// <summary>
/// Runs an API key administration command and writes the output.
/// </summary>
/// <param name="command">API key administration command to execute.</param>
/// <param name="output">Text writer for command output.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task<int> RunAsync(
ApiKeyAdminCommand command,
TextWriter output,
@@ -2,6 +2,9 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyAdminCommandLineParser
{
/// <summary>Parses command-line arguments for the API key admin subcommand.</summary>
/// <param name="args">Command-line arguments to parse.</param>
/// <returns>Parse result containing the command kind and options, or a failure message.</returns>
public static ApiKeyAdminParseResult Parse(IReadOnlyList<string> args)
{
if (args.Count == 0 || !string.Equals(args[0], "apikey", StringComparison.OrdinalIgnoreCase))
@@ -5,16 +5,21 @@ public sealed record ApiKeyAdminParseResult(
ApiKeyAdminCommand? Command,
string? Error)
{
/// <summary>Returns a result indicating the input was not an API key command.</summary>
public static ApiKeyAdminParseResult NotApiKeyCommand()
{
return new ApiKeyAdminParseResult(false, null, null);
}
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
/// <param name="command">Parsed API key administration command.</param>
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
{
return new ApiKeyAdminParseResult(true, command, null);
}
/// <summary>Returns a parse result with the specified error message.</summary>
/// <param name="error">Error message describing the parse failure.</param>
public static ApiKeyAdminParseResult Fail(string error)
{
return new ApiKeyAdminParseResult(true, null, error);
@@ -5,6 +5,10 @@ public sealed class ApiKeyParser : IApiKeyParser
private const string BearerPrefix = "Bearer ";
private const string TokenPrefix = "mxgw_";
/// <summary>Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key with ID and secret, or null if parsing failed.</param>
/// <returns>True if the header was successfully parsed; otherwise, false.</returns>
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
@@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>Reads API key records from SQLite query results.</summary>
public static class ApiKeyRecordReader
{
/// <summary>Deserializes a row from the API key table into an ApiKeyRecord.</summary>
/// <param name="reader">The data reader positioned at the API key row.</param>
/// <returns>The deserialized API key record.</returns>
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
@@ -4,11 +4,17 @@ namespace MxGateway.Server.Security.Authentication;
public static class ApiKeyScopeSerializer
{
/// <summary>Serializes scopes to JSON string.</summary>
/// <param name="scopes">The scopes to serialize.</param>
/// <returns>JSON string representation.</returns>
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
/// <summary>Deserializes scopes from JSON string.</summary>
/// <param name="value">The JSON string to deserialize.</param>
/// <returns>Deserialized scopes set.</returns>
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -2,8 +2,10 @@ using System.Security.Cryptography;
namespace MxGateway.Server.Security.Authentication;
/// <summary>Generates cryptographically secure API key secrets.</summary>
public static class ApiKeySecretGenerator
{
/// <summary>Generates a new random API key secret string.</summary>
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
@@ -9,6 +9,9 @@ public sealed class ApiKeySecretHasher(
IConfiguration configuration,
IOptions<GatewayOptions> options) : IApiKeySecretHasher
{
/// <summary>Hashes an API key secret with pepper using HMAC-SHA256.</summary>
/// <param name="secret">The secret to hash.</param>
/// <returns>The hashed secret.</returns>
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
@@ -5,6 +5,11 @@ public sealed record ApiKeyVerificationResult(
ApiKeyIdentity? Identity,
ApiKeyVerificationFailure Failure)
{
/// <summary>
/// Creates a successful verification result.
/// </summary>
/// <param name="identity">API key identity.</param>
/// <returns>Success result.</returns>
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
{
return new ApiKeyVerificationResult(
@@ -13,6 +18,11 @@ public sealed record ApiKeyVerificationResult(
Failure: ApiKeyVerificationFailure.None);
}
/// <summary>
/// Creates a failed verification result.
/// </summary>
/// <param name="failure">Verification failure reason.</param>
/// <returns>Failure result.</returns>
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
{
return new ApiKeyVerificationResult(
@@ -7,6 +7,12 @@ public sealed class ApiKeyVerifier(
IApiKeySecretHasher hasher,
IApiKeyStore keyStore) : IApiKeyVerifier
{
/// <summary>
/// Verifies an API key from an authorization header asynchronously.
/// </summary>
/// <param name="authorizationHeader">Authorization header value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
@@ -4,8 +4,14 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Factory for creating SQLite connections to the authentication store.
/// </summary>
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
{
/// <summary>
/// Creates and configures a SQLite connection to the auth database.
/// </summary>
public SqliteConnection CreateConnection()
{
string sqlitePath = options.Value.Authentication.SqlitePath;
@@ -3,10 +3,14 @@ using MxGateway.Server.Configuration;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Hosted service that runs authentication store migrations on startup.
/// </summary>
public sealed class AuthStoreMigrationHostedService(
IOptions<GatewayOptions> options,
IAuthStoreMigrator migrator) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
AuthenticationOptions authentication = options.Value.Authentication;
@@ -17,6 +21,7 @@ public sealed class AuthStoreMigrationHostedService(
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
@@ -1,7 +1,15 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Extension methods for configuring the SQLite authentication store.
/// </summary>
public static class AuthStoreServiceCollectionExtensions
{
/// <summary>
/// Adds the SQLite authentication store and related services to the dependency container.
/// </summary>
/// <param name="services">Service collection to configure.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
@@ -2,12 +2,38 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyAdminStore
{
/// <summary>
/// Creates a new API key asynchronously.
/// </summary>
/// <param name="request">API key creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Lists all API keys asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of API key records.</returns>
Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken);
/// <summary>
/// Revokes an API key asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="revokedUtc">Revocation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if revoked; otherwise false.</returns>
Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
/// <summary>
/// Rotates an API key secret asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="secretHash">New secret hash.</param>
/// <param name="rotatedUtc">Rotation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if rotated; otherwise false.</returns>
Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -1,8 +1,23 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// Stores and retrieves audit events for API key operations.
/// </summary>
public interface IApiKeyAuditStore
{
/// <summary>
/// Appends an audit entry to the audit log.
/// </summary>
/// <param name="entry">Audit entry to record.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the append operation.</returns>
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Lists the most recent audit entries, up to the specified count.
/// </summary>
/// <param name="count">Maximum number of entries to return.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task returning the list of audit records.</returns>
Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken);
}
@@ -2,5 +2,8 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeyParser
{
/// <summary>Attempts to parse an authorization header and extract the API key.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key if successful.</param>
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
}
@@ -2,5 +2,7 @@ namespace MxGateway.Server.Security.Authentication;
public interface IApiKeySecretHasher
{
/// <summary>Hashes an API key secret and returns the hash bytes.</summary>
/// <param name="secret">API key secret to hash.</param>
byte[] HashSecret(string secret);
}
@@ -1,10 +1,21 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Persists API keys and audit records for authentication and accounting.</summary>
public interface IApiKeyStore
{
/// <summary>Retrieves an API key by ID regardless of revocation status.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Retrieves an active (non-revoked) API key by ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Records that an API key was used for auditing and tracking.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used in UTC.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
}
@@ -1,7 +1,11 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Verifies API key authorization headers and returns the authenticated identity.</summary>
public interface IApiKeyVerifier
{
/// <summary>Parses and verifies an authorization header, returning success with identity or a failure reason.</summary>
/// <param name="authorizationHeader">The authorization header value to verify.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken);
@@ -1,6 +1,10 @@
namespace MxGateway.Server.Security.Authentication;
/// <summary>Migrates authentication storage between versions.</summary>
public interface IAuthStoreMigrator
{
/// <summary>Performs authentication store migration asynchronously.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the migration operation.</returns>
Task MigrateAsync(CancellationToken cancellationToken);
}
@@ -2,8 +2,12 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>
/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
/// <inheritdoc />
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -35,6 +39,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -60,6 +65,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return records;
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -79,6 +85,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
@@ -4,6 +4,7 @@ namespace MxGateway.Server.Security.Authentication;
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
{
/// <inheritdoc />
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -23,6 +24,7 @@ public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectio
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
if (count <= 0)
@@ -2,18 +2,22 @@ using Microsoft.Data.Sqlite;
namespace MxGateway.Server.Security.Authentication;
/// <summary>SQLite-based store for API key records.</summary>
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
}
/// <inheritdoc />
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -4,6 +4,8 @@ namespace MxGateway.Server.Security.Authentication;
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
{
/// <summary>Applies database migrations to the authentication store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = connectionFactory.CreateConnection();
@@ -12,6 +12,7 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
IGatewayRequestIdentityAccessor identityAccessor,
IOptions<GatewayOptions> options) : Interceptor
{
/// <inheritdoc />
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
@@ -25,6 +26,7 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
}
}
/// <inheritdoc />
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
@@ -39,6 +41,11 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
}
}
/// <summary>Authenticates the API key and authorizes the RPC call by required scope.</summary>
/// <typeparam name="TRequest">Request message type.</typeparam>
/// <param name="request">Request payload.</param>
/// <param name="context">RPC server call context.</param>
/// <returns>Authenticated API key identity, or null if authentication is disabled.</returns>
private async Task<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync<TRequest>(
TRequest request,
ServerCallContext context)
@@ -5,6 +5,11 @@ namespace MxGateway.Server.Security.Authorization;
public sealed class GatewayGrpcScopeResolver
{
/// <summary>
/// Resolves the required authorization scope for a gRPC request.
/// </summary>
/// <param name="request">The gRPC request.</param>
/// <returns>Required authorization scope.</returns>
public string ResolveRequiredScope(object request)
{
return request switch
@@ -6,8 +6,12 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
/// <summary>Gets the current request identity.</summary>
public ApiKeyIdentity? Current => currentIdentity.Value;
/// <summary>Sets the current identity and returns a scope that restores the previous identity.</summary>
/// <param name="identity">The identity to push.</param>
/// <returns>Disposable scope.</returns>
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
@@ -24,6 +28,7 @@ public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAcce
{
private bool disposed;
/// <summary>Restores the previous identity.</summary>
public void Dispose()
{
if (disposed)
@@ -2,8 +2,15 @@ using Grpc.Core.Interceptors;
namespace MxGateway.Server.Security.Authorization;
/// <summary>
/// Extension methods for configuring gRPC authorization services.
/// </summary>
public static class GrpcAuthorizationServiceCollectionExtensions
{
/// <summary>
/// Registers gRPC authorization middleware and scope resolver.
/// </summary>
/// <param name="services">Service collection to register dependencies into.</param>
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
@@ -2,9 +2,13 @@ using MxGateway.Server.Security.Authentication;
namespace MxGateway.Server.Security.Authorization;
/// <summary>Provides scoped access to the current request's API key identity within a gRPC call context.</summary>
public interface IGatewayRequestIdentityAccessor
{
/// <summary>The API key identity of the current request, or null if not set.</summary>
ApiKeyIdentity? Current { get; }
/// <summary>Temporarily pushes an identity onto the scope stack, returning a handle to restore the previous state.</summary>
/// <param name="identity">API key identity to push.</param>
IDisposable Push(ApiKeyIdentity identity);
}
@@ -15,6 +15,20 @@ public sealed class GatewaySession
private bool _closeStarted;
private int _activeEventSubscriberCount;
/// <summary>
/// Initializes a gateway session with session metadata and timeout configuration.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="backendName">Name of the backend MXAccess proxy server.</param>
/// <param name="pipeName">Name of the named pipe for gateway-worker IPC.</param>
/// <param name="nonce">Security nonce for worker validation.</param>
/// <param name="clientIdentity">Client identity from the authentication context.</param>
/// <param name="clientSessionName">Client-supplied session name.</param>
/// <param name="clientCorrelationId">Client-supplied correlation identifier.</param>
/// <param name="commandTimeout">Timeout for command invocation.</param>
/// <param name="startupTimeout">Timeout for worker process startup.</param>
/// <param name="shutdownTimeout">Timeout for worker process shutdown.</param>
/// <param name="openedAt">Timestamp when the session opened.</param>
public GatewaySession(
string sessionId,
string backendName,
@@ -62,32 +76,74 @@ public sealed class GatewaySession
_lastClientActivityAt = openedAt;
}
/// <summary>
/// Gets the session identifier.
/// </summary>
public string SessionId { get; }
/// <summary>
/// Gets the backend MXAccess proxy server name.
/// </summary>
public string BackendName { get; }
/// <summary>
/// Gets the named pipe name for gateway-worker IPC.
/// </summary>
public string PipeName { get; }
/// <summary>
/// Gets the security nonce for worker validation.
/// </summary>
public string Nonce { get; }
/// <summary>
/// Gets the client identity from the authentication context.
/// </summary>
public string? ClientIdentity { get; }
/// <summary>
/// Gets the client-supplied session name.
/// </summary>
public string? ClientSessionName { get; }
/// <summary>
/// Gets the client-supplied correlation identifier.
/// </summary>
public string? ClientCorrelationId { get; }
/// <summary>
/// Gets the command invocation timeout.
/// </summary>
public TimeSpan CommandTimeout { get; }
/// <summary>
/// Gets the worker process startup timeout.
/// </summary>
public TimeSpan StartupTimeout { get; }
/// <summary>
/// Gets the worker process shutdown timeout.
/// </summary>
public TimeSpan ShutdownTimeout { get; }
/// <summary>
/// Gets the timestamp when the session opened.
/// </summary>
public DateTimeOffset OpenedAt { get; }
/// <summary>
/// Gets the worker process identifier, or null if not yet attached.
/// </summary>
public int? WorkerProcessId => _workerClient?.ProcessId;
/// <summary>
/// Gets the attached worker client, or null if not yet attached.
/// </summary>
public IWorkerClient? WorkerClient => _workerClient;
/// <summary>
/// Gets the current session state.
/// </summary>
public SessionState State
{
get
@@ -99,6 +155,9 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Gets the timestamp of the most recent client activity.
/// </summary>
public DateTimeOffset LastClientActivityAt
{
get
@@ -110,6 +169,9 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Gets the lease expiration timestamp, or null if no lease is active.
/// </summary>
public DateTimeOffset? LeaseExpiresAt
{
get
@@ -121,6 +183,9 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Gets the fault description if the session is faulted, or null.
/// </summary>
public string? FinalFault
{
get
@@ -132,6 +197,9 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Gets the count of active event stream subscribers.
/// </summary>
public int ActiveEventSubscriberCount
{
get
@@ -143,6 +211,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Attaches the worker client for this session.
/// </summary>
/// <param name="workerClient">Worker client to attach.</param>
public void AttachWorkerClient(IWorkerClient workerClient)
{
ArgumentNullException.ThrowIfNull(workerClient);
@@ -153,6 +225,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Transitions the session to a new state with constraints for terminal states.
/// </summary>
/// <param name="nextState">Next session state to transition to.</param>
public void TransitionTo(SessionState nextState)
{
lock (_syncRoot)
@@ -171,11 +247,18 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Transitions the session to the Ready state.
/// </summary>
public void MarkReady()
{
TransitionTo(SessionState.Ready);
}
/// <summary>
/// Transitions the session to the Faulted state with a fault description.
/// </summary>
/// <param name="reason">Reason for the fault.</param>
public void MarkFaulted(string reason)
{
lock (_syncRoot)
@@ -190,6 +273,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Updates the timestamp of the most recent client activity.
/// </summary>
/// <param name="activityAt">Timestamp of the client activity.</param>
public void TouchClientActivity(DateTimeOffset activityAt)
{
lock (_syncRoot)
@@ -198,6 +285,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Extends the session lease to the specified expiration time.
/// </summary>
/// <param name="leaseExpiresAt">Timestamp when the lease expires.</param>
public void ExtendLease(DateTimeOffset leaseExpiresAt)
{
lock (_syncRoot)
@@ -206,6 +297,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Determines whether the session lease has expired.
/// </summary>
/// <param name="now">Current timestamp for comparison.</param>
public bool IsLeaseExpired(DateTimeOffset now)
{
lock (_syncRoot)
@@ -214,6 +309,10 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Attaches an event subscriber and returns a disposable lease.
/// </summary>
/// <param name="allowMultipleSubscribers">If true, allows multiple concurrent event subscribers.</param>
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
{
lock (_syncRoot)
@@ -237,6 +336,11 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Invokes a worker command synchronously and returns the reply.
/// </summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
CancellationToken cancellationToken)
@@ -247,6 +351,12 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Executes a bulk add-item command for the specified server and tag addresses.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to add.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> AddItemBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -266,6 +376,12 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk advise-item command for the specified server and item handles.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> AdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -285,6 +401,12 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk remove-item command for the specified server and item handles.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to remove.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> RemoveItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -304,6 +426,12 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk un-advise-item command for the specified server and item handles.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to un-advise.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> UnAdviseItemBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -323,6 +451,12 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk subscribe command for the specified server and tag addresses.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to subscribe to.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -342,6 +476,12 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk unsubscribe command for the specified server and item handles.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="itemHandles">Item handles to unsubscribe from.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<SubscribeResult>> UnsubscribeBulkAsync(
int serverHandle,
IReadOnlyList<int> itemHandles,
@@ -361,6 +501,10 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Reads events from the worker as an asynchronous enumerable stream.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
{
IWorkerClient workerClient = GetReadyWorkerClient();
@@ -369,6 +513,11 @@ public sealed class GatewaySession
return workerClient.ReadEventsAsync(cancellationToken);
}
/// <summary>
/// Closes the session and shuts down the worker process.
/// </summary>
/// <param name="reason">Reason for closing the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public async Task<SessionCloseResult> CloseAsync(
string reason,
CancellationToken cancellationToken)
@@ -426,12 +575,19 @@ public sealed class GatewaySession
}
}
/// <summary>
/// Terminates the worker process immediately.
/// </summary>
/// <param name="reason">Reason for killing the worker.</param>
public void KillWorker(string reason)
{
_workerClient?.Kill(reason);
TransitionTo(SessionState.Closed);
}
/// <summary>
/// Disposes the session and frees associated resources.
/// </summary>
public async ValueTask DisposeAsync()
{
_closeLock.Dispose();
@@ -500,6 +656,9 @@ public sealed class GatewaySession
{
private bool _disposed;
/// <summary>
/// Disposes the lease and detaches the event subscriber.
/// </summary>
public void Dispose()
{
if (_disposed)
@@ -4,31 +4,59 @@ namespace MxGateway.Server.Sessions;
public interface ISessionManager
{
/// <summary>Opens a new gateway session and launches a worker process.</summary>
/// <param name="request">Request payload.</param>
/// <param name="clientIdentity">Client identity string.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The newly opened session.</returns>
Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
CancellationToken cancellationToken);
/// <summary>Attempts to retrieve a session by ID.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session, if found.</param>
/// <returns>True if the session exists; otherwise false.</returns>
bool TryGetSession(
string sessionId,
out GatewaySession session);
/// <summary>Invokes a command on the worker for the specified session.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="command">Command to invoke.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The command reply from the worker.</returns>
Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken);
/// <summary>Reads events streamed from the worker for the specified session.</summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Events emitted by the worker.</returns>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken);
/// <summary>Closes a session and terminates its worker process.</summary>
/// <param name="sessionId">Identifier of the session to close.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The result of closing the session.</returns>
Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken);
/// <summary>Closes all sessions with expired leases at the specified time.</summary>
/// <param name="now">The current time to evaluate expiration against.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>The number of sessions closed.</returns>
Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken);
/// <summary>Shuts down all sessions and the session manager.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task ShutdownAsync(CancellationToken cancellationToken);
}
@@ -1,16 +1,46 @@
namespace MxGateway.Server.Sessions;
/// <summary>
/// Registry for managing active gateway sessions.
/// </summary>
public interface ISessionRegistry
{
/// <summary>
/// Total number of sessions (open or closed) in the registry.
/// </summary>
int Count { get; }
/// <summary>
/// Number of sessions currently in an active (open) state.
/// </summary>
int ActiveCount { get; }
/// <summary>
/// Attempts to add a session to the registry; returns false if session ID already exists.
/// </summary>
/// <param name="session">Session to add.</param>
/// <returns>True if added; false if session ID already exists.</returns>
bool TryAdd(GatewaySession session);
/// <summary>
/// Attempts to retrieve a session by ID; returns false if not found.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session, if found.</param>
/// <returns>True if found; false otherwise.</returns>
bool TryGet(string sessionId, out GatewaySession session);
/// <summary>
/// Attempts to remove a session by ID; returns false if not found.
/// </summary>
/// <param name="sessionId">Identifier of the session to remove.</param>
/// <param name="session">The removed session, if found.</param>
/// <returns>True if removed; false if not found.</returns>
bool TryRemove(string sessionId, out GatewaySession session);
/// <summary>
/// Returns a snapshot of all sessions in the registry.
/// </summary>
/// <returns>A read-only collection of all sessions.</returns>
IReadOnlyCollection<GatewaySession> Snapshot();
}
@@ -1,7 +1,16 @@
namespace MxGateway.Server.Sessions;
/// <summary>
/// Creates worker client instances for gateway sessions.
/// </summary>
public interface ISessionWorkerClientFactory
{
/// <summary>
/// Creates a worker client for the specified session.
/// </summary>
/// <param name="session">Session to create a worker client for.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A worker client connected to the worker process.</returns>
Task<MxGateway.Server.Workers.IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken);
@@ -2,6 +2,9 @@ namespace MxGateway.Server.Sessions;
internal sealed class SessionCloseStartedException : Exception
{
/// <summary>Initializes a new instance of the <see cref="SessionCloseStartedException"/> class.</summary>
/// <param name="message">The exception message.</param>
/// <param name="innerException">The exception that caused this error.</param>
public SessionCloseStartedException(
string message,
Exception innerException)
@@ -25,6 +25,15 @@ public sealed class SessionManager : ISessionManager
private readonly GatewayOptions _options;
private readonly SemaphoreSlim _sessionSlots;
/// <summary>
/// Initializes a new instance of <see cref="SessionManager"/>.
/// </summary>
/// <param name="registry">Session registry.</param>
/// <param name="workerClientFactory">Worker client factory.</param>
/// <param name="options">Gateway options.</param>
/// <param name="metrics">Gateway metrics.</param>
/// <param name="timeProvider">Time provider for timestamps.</param>
/// <param name="logger">Logger.</param>
public SessionManager(
ISessionRegistry registry,
ISessionWorkerClientFactory workerClientFactory,
@@ -43,6 +52,13 @@ public sealed class SessionManager : ISessionManager
_sessionSlots = new SemaphoreSlim(_options.Sessions.MaxSessions, _options.Sessions.MaxSessions);
}
/// <summary>
/// Opens a new gateway session and connects to the worker.
/// </summary>
/// <param name="request">Session open request.</param>
/// <param name="clientIdentity">Client authentication identity.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Opened gateway session.</returns>
public async Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
@@ -96,6 +112,12 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Attempts to retrieve a session by ID.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="session">The session if found.</param>
/// <returns>True if session found; otherwise false.</returns>
public bool TryGetSession(
string sessionId,
out GatewaySession session)
@@ -103,6 +125,13 @@ public sealed class SessionManager : ISessionManager
return _registry.TryGet(sessionId, out session);
}
/// <summary>
/// Invokes a worker command on a session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="command">Worker command.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Command reply.</returns>
public async Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
@@ -129,6 +158,12 @@ public sealed class SessionManager : ISessionManager
}
}
/// <summary>
/// Reads events from a session's event stream asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of worker events.</returns>
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -138,6 +173,12 @@ public sealed class SessionManager : ISessionManager
return session.ReadEventsAsync(cancellationToken);
}
/// <summary>
/// Closes a gateway session asynchronously.
/// </summary>
/// <param name="sessionId">Session identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Session close result.</returns>
public async Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken)
@@ -151,6 +192,12 @@ public sealed class SessionManager : ISessionManager
return result;
}
/// <summary>
/// Closes all sessions with expired leases asynchronously.
/// </summary>
/// <param name="now">Current time for lease expiration check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Count of sessions closed.</returns>
public async Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken)
@@ -170,6 +217,11 @@ public sealed class SessionManager : ISessionManager
return closedCount;
}
/// <summary>
/// Shuts down all active sessions gracefully asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (GatewaySession session in _registry.Snapshot())
@@ -2,6 +2,11 @@ namespace MxGateway.Server.Sessions;
public sealed class SessionManagerException : Exception
{
/// <summary>
/// Initializes a new instance of <see cref="SessionManagerException"/>.
/// </summary>
/// <param name="errorCode">Session manager error code.</param>
/// <param name="message">Exception message.</param>
public SessionManagerException(
SessionManagerErrorCode errorCode,
string message)
@@ -10,6 +15,12 @@ public sealed class SessionManagerException : Exception
ErrorCode = errorCode;
}
/// <summary>
/// Initializes a new instance of <see cref="SessionManagerException"/> with an inner exception.
/// </summary>
/// <param name="errorCode">Session manager error code.</param>
/// <param name="message">Exception message.</param>
/// <param name="innerException">Inner exception.</param>
public SessionManagerException(
SessionManagerErrorCode errorCode,
string message,
@@ -19,5 +30,8 @@ public sealed class SessionManagerException : Exception
ErrorCode = errorCode;
}
/// <summary>
/// Gets the session manager error code.
/// </summary>
public SessionManagerErrorCode ErrorCode { get; }
}
@@ -9,6 +9,8 @@ public sealed record SessionOpenRequest(
string? ClientCorrelationId,
Duration? CommandTimeout)
{
/// <summary>Creates a SessionOpenRequest from a gRPC OpenSessionRequest contract.</summary>
/// <param name="request">Request payload.</param>
public static SessionOpenRequest FromContract(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
@@ -3,14 +3,27 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Sessions;
/// <summary>
/// Thread-safe registry of active gateway sessions.
/// </summary>
public sealed class SessionRegistry : ISessionRegistry
{
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
/// <summary>
/// Gets the total count of sessions in the registry.
/// </summary>
public int Count => _sessions.Count;
/// <summary>
/// Gets the count of non-closed sessions.
/// </summary>
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
/// <summary>
/// Adds a session to the registry.
/// </summary>
/// <param name="session">Gateway session to add.</param>
public bool TryAdd(GatewaySession session)
{
ArgumentNullException.ThrowIfNull(session);
@@ -18,6 +31,11 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryAdd(session.SessionId, session);
}
/// <summary>
/// Retrieves a session by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The retrieved session if found.</param>
public bool TryGet(
string sessionId,
out GatewaySession session)
@@ -25,6 +43,11 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryGetValue(sessionId, out session!);
}
/// <summary>
/// Removes a session from the registry by identifier.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="session">The removed session if found.</param>
public bool TryRemove(
string sessionId,
out GatewaySession session)
@@ -32,6 +55,9 @@ public sealed class SessionRegistry : ISessionRegistry
return _sessions.TryRemove(sessionId, out session!);
}
/// <summary>
/// Returns a snapshot of all sessions in the registry.
/// </summary>
public IReadOnlyCollection<GatewaySession> Snapshot()
{
return _sessions.Values.ToArray();
@@ -1,7 +1,11 @@
namespace MxGateway.Server.Sessions;
/// <summary>Service collection extensions for session management.</summary>
public static class SessionServiceCollectionExtensions
{
/// <summary>Registers gateway session registry, manager, and factory services.</summary>
/// <param name="services">Service collection to register services in.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddGatewaySessions(this IServiceCollection services)
{
services.AddSingleton<ISessionRegistry, SessionRegistry>();
@@ -3,15 +3,18 @@ using Microsoft.Extensions.Logging;
namespace MxGateway.Server.Sessions;
/// <summary>Hosted service that cleanly shuts down all gateway sessions on application shutdown.</summary>
public sealed class SessionShutdownHostedService(
ISessionManager sessionManager,
ILogger<SessionShutdownHostedService> logger) : IHostedService
{
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
try
@@ -9,6 +9,7 @@ using MxGateway.Server.Workers;
namespace MxGateway.Server.Sessions;
/// <summary>Factory for creating worker clients and launching worker processes.</summary>
public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
{
private readonly IWorkerProcessLauncher _workerProcessLauncher;
@@ -17,6 +18,12 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
private readonly ILoggerFactory _loggerFactory;
private readonly GatewayOptions _options;
/// <summary>Initializes a new instance of the SessionWorkerClientFactory class.</summary>
/// <param name="workerProcessLauncher">Service for launching worker processes.</param>
/// <param name="options">Configuration options.</param>
/// <param name="metrics">Metrics collector for gateway events.</param>
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
/// <param name="timeProvider">Optional time provider for testing; defaults to system time.</param>
public SessionWorkerClientFactory(
IWorkerProcessLauncher workerProcessLauncher,
IOptions<GatewayOptions> options,
@@ -32,6 +39,10 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
_options = options.Value;
}
/// <summary>Creates a worker client and launches the worker process.</summary>
/// <param name="session">The gateway session.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The created worker client.</returns>
public async Task<IWorkerClient> CreateAsync(
GatewaySession session,
CancellationToken cancellationToken)
@@ -144,6 +155,9 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
}
}
/// <summary>Creates a named pipe for worker communication.</summary>
/// <param name="pipeName">The pipe name.</param>
/// <returns>Named pipe server stream.</returns>
private static NamedPipeServerStream CreatePipe(string pipeName)
{
return new NamedPipeServerStream(
@@ -154,6 +168,9 @@ public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
PipeOptions.Asynchronous);
}
/// <summary>Waits for a client to connect to the pipe.</summary>
/// <param name="pipe">The named pipe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private static async Task WaitForPipeConnectionAsync(
NamedPipeServerStream pipe,
CancellationToken cancellationToken)
@@ -2,26 +2,44 @@ using MxGateway.Contracts.Proto;
namespace MxGateway.Server.Workers;
/// <summary>Manages communication with a single worker process via a named pipe.</summary>
public interface IWorkerClient : IAsyncDisposable
{
/// <summary>Unique session identifier for this worker.</summary>
string SessionId { get; }
/// <summary>Process ID of the worker, or null before handshake completes.</summary>
int? ProcessId { get; }
/// <summary>Current state of the worker connection.</summary>
WorkerClientState State { get; }
/// <summary>UTC timestamp of the most recent heartbeat from the worker.</summary>
DateTimeOffset LastHeartbeatAt { get; }
/// <summary>Initiates the handshake and enters ready state.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task StartAsync(CancellationToken cancellationToken);
/// <summary>Sends a command to the worker and waits for a reply.</summary>
/// <param name="command">Worker command to invoke.</param>
/// <param name="timeout">Timeout for waiting for the reply.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<WorkerCommandReply> InvokeAsync(
WorkerCommand command,
TimeSpan timeout,
CancellationToken cancellationToken);
/// <summary>Reads events from the worker as they arrive.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
/// <summary>Gracefully shuts down the worker by closing the connection.</summary>
/// <param name="timeout">Timeout for shutdown.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
/// <summary>Terminates the worker process immediately with a diagnostic reason.</summary>
/// <param name="reason">Reason for terminating the worker.</param>
void Kill(string reason);
}
@@ -1,14 +1,34 @@
namespace MxGateway.Server.Workers;
/// <summary>
/// Abstraction over a worker process with lifecycle and exit-code operations.
/// </summary>
public interface IWorkerProcess : IDisposable
{
/// <summary>
/// The process ID.
/// </summary>
int Id { get; }
/// <summary>
/// Indicates whether the process has exited.
/// </summary>
bool HasExited { get; }
/// <summary>
/// The exit code if the process has exited; otherwise null.
/// </summary>
int? ExitCode { get; }
/// <summary>
/// Waits for the process to exit with the specified cancellation token.
/// </summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
ValueTask WaitForExitAsync(CancellationToken cancellationToken);
/// <summary>
/// Kills the process, optionally terminating the entire process tree.
/// </summary>
/// <param name="entireProcessTree">If true, terminate all child processes; otherwise terminate only this process.</param>
void Kill(bool entireProcessTree);
}
@@ -2,7 +2,11 @@ using System.Diagnostics;
namespace MxGateway.Server.Workers;
/// <summary>Factory for creating and starting worker processes.</summary>
public interface IWorkerProcessFactory
{
/// <summary>Starts a worker process with the specified start information.</summary>
/// <param name="startInfo">Process start configuration.</param>
/// <returns>The started worker process.</returns>
IWorkerProcess Start(ProcessStartInfo startInfo);
}
@@ -2,6 +2,10 @@ namespace MxGateway.Server.Workers;
public interface IWorkerProcessLauncher
{
/// <summary>Launches a new worker process with the specified configuration.</summary>
/// <param name="request">The launch request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The worker process handle.</returns>
Task<WorkerProcessHandle> LaunchAsync(
WorkerProcessLaunchRequest request,
CancellationToken cancellationToken = default);
@@ -2,6 +2,13 @@ namespace MxGateway.Server.Workers;
public interface IWorkerStartupProbe
{
/// <summary>
/// Waits for the worker process to reach a ready state asynchronously.
/// </summary>
/// <param name="process">Worker process to probe.</param>
/// <param name="request">Worker launch request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
Task WaitUntilReadyAsync(
IWorkerProcess process,
WorkerProcessLaunchRequest request,
@@ -2,24 +2,33 @@ using System.Diagnostics;
namespace MxGateway.Server.Workers;
/// <summary>
/// Wraps a System.Diagnostics.Process as an IWorkerProcess.
/// </summary>
internal sealed class SystemWorkerProcess(Process process) : IWorkerProcess
{
/// <inheritdoc />
public int Id => process.Id;
/// <inheritdoc />
public bool HasExited => process.HasExited;
/// <inheritdoc />
public int? ExitCode => process.HasExited ? process.ExitCode : null;
/// <inheritdoc />
public async ValueTask WaitForExitAsync(CancellationToken cancellationToken)
{
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Kill(bool entireProcessTree)
{
process.Kill(entireProcessTree);
}
/// <inheritdoc />
public void Dispose()
{
process.Dispose();

Some files were not shown because too many files have changed in this diff Show More