Add XML documentation across gateway, worker, and .NET client
This commit is contained in:
@@ -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)
|
||||
|
||||
+7
@@ -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
Reference in New Issue
Block a user