Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7929cc12f | |||
| d890eff862 | |||
| 9dcd4baff2 | |||
| 7a0743496f | |||
| bcfbd1cfc8 | |||
| 8e3b0c1c4a | |||
| bd4be85f26 | |||
| 7331c6157a | |||
| cbc317e3e7 | |||
| 7242cf772b | |||
| 7d67313a7d | |||
| 044b16c5db | |||
| 1f92078777 | |||
| 4a3560c7ee | |||
| 108a3d3f8a | |||
| 95e71cd819 | |||
| 647fe9a4b5 | |||
| dd455089b4 | |||
| d0bc4e3c01 | |||
| 6a40d26366 | |||
| 366f57198f | |||
| aab41e04ab | |||
| 3be92a17bd | |||
| a871f2f2e5 | |||
| 7b86bab705 | |||
| 56886c3b4e | |||
| a3ccd5c80b | |||
| 0fd954d94c | |||
| 91f2d8dc14 | |||
| fb425da009 | |||
| c7e4c4b614 | |||
| 59c710d789 | |||
| 862f119b91 | |||
| 35e4442c7b | |||
| ed1018c3bb | |||
| 2e4ba11a9f | |||
| ff86b3f0b0 | |||
| 653f17c669 | |||
| 556c3bfa83 | |||
| 9b3637257c | |||
| 77eac95f33 | |||
| 015fa1f50d | |||
| dede407304 | |||
| 0d96963c99 | |||
| 3661420f0a | |||
| 14419853c7 | |||
| a20517f5ad | |||
| 626e7762d9 | |||
| 8d6d3f6188 | |||
| 276288ad87 | |||
| 76bd3de5a2 | |||
| 29455fc1f6 | |||
| 5511609880 | |||
| 451dccf7e3 | |||
| cde9c89386 | |||
| d496f1fd75 | |||
| 6559672fc1 | |||
| 97c30b9d00 | |||
| 603aff7004 | |||
| e81682e367 | |||
| d5a982152b | |||
| 0b0be7098e | |||
| fce9e99553 | |||
| c8fb3e91a3 | |||
| 8ce327e6f4 | |||
| fad0ac9948 | |||
| 9cb2f1c5cd | |||
| da9ffe0e11 | |||
| 0af1427859 | |||
| e2b4dfcb32 | |||
| 3b3e41acf4 | |||
| c1188c6957 | |||
| 4094e64ee0 | |||
| 696be17139 | |||
| b42c3c8b3b | |||
| 420a813967 | |||
| ec1155de6d | |||
| 0c539834dc | |||
| a5098e6815 | |||
| 41ddd122a6 | |||
| a25f09e795 | |||
| 37da9d8f44 | |||
| a19af5f7cb | |||
| 03ab36c4d5 | |||
| 91ea71b0b7 | |||
| 7dfec6dc8c | |||
| a462f68dbd | |||
| 16c18954b6 |
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
using MxGateway.Client;
|
||||
|
||||
namespace MxGateway.Client.Cli;
|
||||
|
||||
public static class MxGatewayClientCli
|
||||
{
|
||||
public static int Run(
|
||||
string[] args,
|
||||
TextWriter standardOutput,
|
||||
TextWriter standardError)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
ArgumentNullException.ThrowIfNull(standardOutput);
|
||||
ArgumentNullException.ThrowIfNull(standardError);
|
||||
|
||||
if (args.Length is 0 || IsHelp(args[0]))
|
||||
{
|
||||
WriteUsage(standardOutput);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
standardOutput.WriteLine(
|
||||
$"gateway-protocol={MxGatewayClientContractInfo.GatewayProtocolVersion}");
|
||||
standardOutput.WriteLine(
|
||||
$"worker-protocol={MxGatewayClientContractInfo.WorkerProtocolVersion}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
standardError.WriteLine($"Unknown command '{args[0]}'.");
|
||||
WriteUsage(standardError);
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static bool IsHelp(string value)
|
||||
{
|
||||
return string.Equals(value, "-h", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "--help", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, "help", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void WriteUsage(TextWriter writer)
|
||||
{
|
||||
writer.WriteLine("mxgw-dotnet version");
|
||||
writer.WriteLine("mxgw-dotnet --help");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using MxGateway.Client.Cli;
|
||||
|
||||
return MxGatewayClientCli.Run(args, Console.Out, Console.Error);
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MxGateway.Client\MxGateway.Client.csproj" />
|
||||
<ProjectReference Include="..\MxGateway.Client.Cli\MxGateway.Client.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,20 @@
|
||||
using MxGateway.Client.Cli;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayClientCliTests
|
||||
{
|
||||
[Fact]
|
||||
public void Run_Version_PrintsCompiledProtocolVersions()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("gateway-protocol=1", output.ToString());
|
||||
Assert.Contains("worker-protocol=1", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayClientContractInfoTests
|
||||
{
|
||||
[Fact]
|
||||
public void GatewayProtocolVersion_MatchesSharedContract()
|
||||
{
|
||||
Assert.Equal(
|
||||
GatewayContractInfo.GatewayProtocolVersion,
|
||||
MxGatewayClientContractInfo.GatewayProtocolVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorkerProtocolVersion_MatchesSharedContract()
|
||||
{
|
||||
Assert.Equal(
|
||||
GatewayContractInfo.WorkerProtocolVersion,
|
||||
MxGatewayClientContractInfo.WorkerProtocolVersion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayClientOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Validate_WithAbsoluteEndpointAndApiKey_Succeeds()
|
||||
{
|
||||
var options = new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
};
|
||||
|
||||
options.Validate();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithEmptyApiKey_Throws()
|
||||
{
|
||||
var options = new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "",
|
||||
};
|
||||
|
||||
Assert.Throws<ArgumentException>(options.Validate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MxGateway.Client.Tests;
|
||||
|
||||
public sealed class MxGatewayGeneratedContractTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||
{
|
||||
var options = new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri("http://localhost:5000"),
|
||||
ApiKey = "test-api-key",
|
||||
};
|
||||
|
||||
await using var client = MxGatewayClient.Create(options);
|
||||
|
||||
Assert.NotNull(client.RawClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client", "MxGateway.Client\MxGateway.Client.csproj", "{7CF9ED88-1F32-4040-BEB1-D0902E304C70}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Contracts", "..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj", "{9AB807A8-0469-40F7-A000-D240F36B6E5D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Cli", "MxGateway.Client.Cli\MxGateway.Client.Cli.csproj", "{EB061E77-2475-4322-9257-3F2456DD141C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MxGateway.Client.Tests", "MxGateway.Client.Tests\MxGateway.Client.Tests.csproj", "{B77B5A8E-0C53-4419-9BCD-227C9753A074}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7CF9ED88-1F32-4040-BEB1-D0902E304C70}.Release|x86.Build.0 = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{9AB807A8-0469-40F7-A000-D240F36B6E5D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EB061E77-2475-4322-9257-3F2456DD141C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x64.Build.0 = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{B77B5A8E-0C53-4419-9BCD-227C9753A074}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\MxGateway.Contracts\MxGateway.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Grpc.Net.Client" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,41 @@
|
||||
using Grpc.Net.Client;
|
||||
using MxGateway.Contracts.Proto;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the initial .NET client entry point and raw generated gRPC client.
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClient : IAsyncDisposable
|
||||
{
|
||||
private readonly GrpcChannel _channel;
|
||||
|
||||
private MxGatewayClient(GrpcChannel channel)
|
||||
{
|
||||
_channel = channel;
|
||||
RawClient = new MxAccessGateway.MxAccessGatewayClient(channel);
|
||||
}
|
||||
|
||||
public MxAccessGateway.MxAccessGatewayClient RawClient { get; }
|
||||
|
||||
public static MxGatewayClient Create(MxGatewayClientOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.Validate();
|
||||
|
||||
var channel = GrpcChannel.ForAddress(
|
||||
options.Endpoint,
|
||||
new GrpcChannelOptions
|
||||
{
|
||||
LoggerFactory = options.LoggerFactory,
|
||||
});
|
||||
|
||||
return new MxGatewayClient(channel);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_channel.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the protocol versions compiled into this client package.
|
||||
/// </summary>
|
||||
public static class MxGatewayClientContractInfo
|
||||
{
|
||||
public const uint GatewayProtocolVersion =
|
||||
GatewayContractInfo.GatewayProtocolVersion;
|
||||
|
||||
public const uint WorkerProtocolVersion =
|
||||
GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MxGateway.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the gRPC channel used by the .NET MXAccess Gateway client.
|
||||
/// </summary>
|
||||
public sealed class MxGatewayClientOptions
|
||||
{
|
||||
public required Uri Endpoint { get; init; }
|
||||
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
public string? CaCertificatePath { get; init; }
|
||||
|
||||
public string? ServerNameOverride { get; init; }
|
||||
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
|
||||
public TimeSpan DefaultCallTimeout { get; init; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
public ILoggerFactory? LoggerFactory { get; init; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(Endpoint);
|
||||
|
||||
if (!Endpoint.IsAbsoluteUri)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The gateway endpoint must be an absolute URI.",
|
||||
nameof(Endpoint));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The gateway API key must not be empty.",
|
||||
nameof(ApiKey));
|
||||
}
|
||||
|
||||
if (ConnectTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(ConnectTimeout),
|
||||
"The connect timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (DefaultCallTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(DefaultCallTimeout),
|
||||
"The default call timeout must be greater than zero.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# .NET Client Projects
|
||||
|
||||
The .NET client workspace contains the MXAccess Gateway client library, test
|
||||
CLI, and unit tests.
|
||||
|
||||
## Projects
|
||||
|
||||
| Project | Purpose |
|
||||
|---------|---------|
|
||||
| `MxGateway.Client` | .NET 10 library entry point and raw gRPC client access. |
|
||||
| `MxGateway.Client.Cli` | Test CLI for smoke and diagnostic commands. |
|
||||
| `MxGateway.Client.Tests` | Unit tests for the scaffold and generated contract wiring. |
|
||||
|
||||
The projects reference `src/MxGateway.Contracts/MxGateway.Contracts.csproj` so
|
||||
the client compiles against the same generated protobuf and gRPC types as the
|
||||
gateway. `clients/dotnet/generated` remains reserved for generator output if a
|
||||
future client build switches to client-local `Grpc.Tools` generation.
|
||||
|
||||
## Build And Test
|
||||
|
||||
```powershell
|
||||
dotnet build clients/dotnet/MxGateway.Client.sln
|
||||
dotnet test clients/dotnet/MxGateway.Client.sln --no-build
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Go Client
|
||||
|
||||
The Go client module contains the generated MXAccess Gateway protobuf bindings,
|
||||
a small handwritten `mxgateway` package, and the `mxgw-go` test CLI scaffold.
|
||||
The module uses the shared proto inputs documented in
|
||||
`../../docs/client-proto-generation.md` so gateway and client contracts stay in
|
||||
sync.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
clients/go/
|
||||
go.mod
|
||||
generate-proto.ps1
|
||||
internal/generated/
|
||||
mxgateway/
|
||||
cmd/mxgw-go/
|
||||
```
|
||||
|
||||
`internal/generated` contains code produced by `protoc`, `protoc-gen-go`, and
|
||||
`protoc-gen-go-grpc`. Do not edit generated files by hand.
|
||||
|
||||
## Regenerating Protobuf Bindings
|
||||
|
||||
Run generation after the shared `.proto` files or the Go output path changes:
|
||||
|
||||
```powershell
|
||||
./generate-proto.ps1
|
||||
```
|
||||
|
||||
The script uses the tool paths recorded in `../../docs/toolchain-links.md`.
|
||||
|
||||
## Build And Test
|
||||
|
||||
Run the Go module checks from `clients/go`:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
The scaffold tests parse the shared golden JSON fixtures with the generated Go
|
||||
types. Later client implementation tests add fake gRPC services, auth metadata,
|
||||
streaming, value conversion, and CLI behavior.
|
||||
|
||||
## CLI
|
||||
|
||||
The scaffold CLI exposes version information:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/mxgw-go version -json
|
||||
```
|
||||
|
||||
Additional commands are implemented with the client/session wrapper work.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
)
|
||||
|
||||
type versionOutput struct {
|
||||
ClientVersion string `json:"clientVersion"`
|
||||
GatewayProtocolVersion uint32 `json:"gatewayProtocolVersion"`
|
||||
WorkerProtocolVersion uint32 `json:"workerProtocolVersion"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: mxgw-go version [-json]")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "version":
|
||||
return runVersion(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runVersion(args []string) error {
|
||||
flags := flag.NewFlagSet("version", flag.ContinueOnError)
|
||||
flags.SetOutput(os.Stderr)
|
||||
jsonOutput := flags.Bool("json", false, "write JSON output")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output := versionOutput{
|
||||
ClientVersion: mxgateway.ClientVersion,
|
||||
GatewayProtocolVersion: mxgateway.GatewayProtocolVersion,
|
||||
WorkerProtocolVersion: mxgateway.WorkerProtocolVersion,
|
||||
}
|
||||
|
||||
if *jsonOutput {
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(output)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "mxgw-go %s\n", output.ClientVersion)
|
||||
fmt.Fprintf(os.Stdout, "gateway protocol %d\n", output.GatewayProtocolVersion)
|
||||
fmt.Fprintf(os.Stdout, "worker protocol %d\n", output.WorkerProtocolVersion)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$protoRoot = Join-Path $repoRoot 'src\MxGateway.Contracts\Protos'
|
||||
$outputRoot = Join-Path $PSScriptRoot 'internal\generated'
|
||||
$modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated'
|
||||
$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe'
|
||||
$goPluginPath = 'C:\Users\dohertj2\go\bin'
|
||||
|
||||
if (-not (Test-Path $protoc)) {
|
||||
throw "protoc was not found at $protoc. See docs/toolchain-links.md."
|
||||
}
|
||||
|
||||
foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) {
|
||||
$pluginPath = Join-Path $goPluginPath $pluginName
|
||||
if (-not (Test-Path $pluginPath)) {
|
||||
throw "$pluginName was not found at $pluginPath. See docs/toolchain-links.md."
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
|
||||
Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item
|
||||
|
||||
$env:Path = "$goPluginPath;$env:Path"
|
||||
|
||||
& $protoc `
|
||||
--proto_path=$protoRoot `
|
||||
--go_out=$outputRoot `
|
||||
--go_opt=paths=source_relative `
|
||||
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
|
||||
mxaccess_gateway.proto `
|
||||
mxaccess_worker.proto
|
||||
|
||||
& $protoc `
|
||||
--proto_path=$protoRoot `
|
||||
--go-grpc_out=$outputRoot `
|
||||
--go-grpc_opt=paths=source_relative `
|
||||
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
|
||||
mxaccess_gateway.proto
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc v7.34.1
|
||||
// source: mxaccess_gateway.proto
|
||||
|
||||
package generated
|
||||
|
||||
import (
|
||||
context "context"
|
||||
grpc "google.golang.org/grpc"
|
||||
codes "google.golang.org/grpc/codes"
|
||||
status "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// This is a compile-time assertion to ensure that this generated file
|
||||
// is compatible with the grpc package it is being compiled against.
|
||||
// Requires gRPC-Go v1.64.0 or later.
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
MxAccessGateway_OpenSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/OpenSession"
|
||||
MxAccessGateway_CloseSession_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/CloseSession"
|
||||
MxAccessGateway_Invoke_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/Invoke"
|
||||
MxAccessGateway_StreamEvents_FullMethodName = "/mxaccess_gateway.v1.MxAccessGateway/StreamEvents"
|
||||
)
|
||||
|
||||
// MxAccessGatewayClient is the client API for MxAccessGateway service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// Public client API for MXAccess sessions hosted by the gateway.
|
||||
type MxAccessGatewayClient interface {
|
||||
OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error)
|
||||
CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error)
|
||||
Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error)
|
||||
StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error)
|
||||
}
|
||||
|
||||
type mxAccessGatewayClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewMxAccessGatewayClient(cc grpc.ClientConnInterface) MxAccessGatewayClient {
|
||||
return &mxAccessGatewayClient{cc}
|
||||
}
|
||||
|
||||
func (c *mxAccessGatewayClient) OpenSession(ctx context.Context, in *OpenSessionRequest, opts ...grpc.CallOption) (*OpenSessionReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(OpenSessionReply)
|
||||
err := c.cc.Invoke(ctx, MxAccessGateway_OpenSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *mxAccessGatewayClient) CloseSession(ctx context.Context, in *CloseSessionRequest, opts ...grpc.CallOption) (*CloseSessionReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CloseSessionReply)
|
||||
err := c.cc.Invoke(ctx, MxAccessGateway_CloseSession_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *mxAccessGatewayClient) Invoke(ctx context.Context, in *MxCommandRequest, opts ...grpc.CallOption) (*MxCommandReply, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MxCommandReply)
|
||||
err := c.cc.Invoke(ctx, MxAccessGateway_Invoke_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *mxAccessGatewayClient) StreamEvents(ctx context.Context, in *StreamEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[MxEvent], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &MxAccessGateway_ServiceDesc.Streams[0], MxAccessGateway_StreamEvents_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[StreamEventsRequest, MxEvent]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := x.ClientStream.CloseSend(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MxAccessGateway_StreamEventsClient = grpc.ServerStreamingClient[MxEvent]
|
||||
|
||||
// MxAccessGatewayServer is the server API for MxAccessGateway service.
|
||||
// All implementations must embed UnimplementedMxAccessGatewayServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// Public client API for MXAccess sessions hosted by the gateway.
|
||||
type MxAccessGatewayServer interface {
|
||||
OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error)
|
||||
CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error)
|
||||
Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error)
|
||||
StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error
|
||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||
}
|
||||
|
||||
// UnimplementedMxAccessGatewayServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedMxAccessGatewayServer struct{}
|
||||
|
||||
func (UnimplementedMxAccessGatewayServer) OpenSession(context.Context, *OpenSessionRequest) (*OpenSessionReply, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method OpenSession not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) CloseSession(context.Context, *CloseSessionRequest) (*CloseSessionReply, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CloseSession not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) Invoke(context.Context, *MxCommandRequest) (*MxCommandReply, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Invoke not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) StreamEvents(*StreamEventsRequest, grpc.ServerStreamingServer[MxEvent]) error {
|
||||
return status.Error(codes.Unimplemented, "method StreamEvents not implemented")
|
||||
}
|
||||
func (UnimplementedMxAccessGatewayServer) mustEmbedUnimplementedMxAccessGatewayServer() {}
|
||||
func (UnimplementedMxAccessGatewayServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeMxAccessGatewayServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to MxAccessGatewayServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeMxAccessGatewayServer interface {
|
||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||
}
|
||||
|
||||
func RegisterMxAccessGatewayServer(s grpc.ServiceRegistrar, srv MxAccessGatewayServer) {
|
||||
// If the following call panics, it indicates UnimplementedMxAccessGatewayServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&MxAccessGateway_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _MxAccessGateway_OpenSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(OpenSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MxAccessGatewayServer).OpenSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MxAccessGateway_OpenSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MxAccessGatewayServer).OpenSession(ctx, req.(*OpenSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MxAccessGateway_CloseSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CloseSessionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MxAccessGatewayServer).CloseSession(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MxAccessGateway_CloseSession_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MxAccessGatewayServer).CloseSession(ctx, req.(*CloseSessionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MxAccessGateway_Invoke_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MxCommandRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(MxAccessGatewayServer).Invoke(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: MxAccessGateway_Invoke_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(MxAccessGatewayServer).Invoke(ctx, req.(*MxCommandRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _MxAccessGateway_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(StreamEventsRequest)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(MxAccessGatewayServer).StreamEvents(m, &grpc.GenericServerStream[StreamEventsRequest, MxEvent]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type MxAccessGateway_StreamEventsServer = grpc.ServerStreamingServer[MxEvent]
|
||||
|
||||
// MxAccessGateway_ServiceDesc is the grpc.ServiceDesc for MxAccessGateway service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var MxAccessGateway_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "mxaccess_gateway.v1.MxAccessGateway",
|
||||
HandlerType: (*MxAccessGatewayServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "OpenSession",
|
||||
Handler: _MxAccessGateway_OpenSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CloseSession",
|
||||
Handler: _MxAccessGateway_CloseSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "Invoke",
|
||||
Handler: _MxAccessGateway_Invoke_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "StreamEvents",
|
||||
Handler: _MxAccessGateway_StreamEvents_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "mxaccess_gateway.proto",
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
package mxgateway
|
||||
|
||||
import "strings"
|
||||
|
||||
// Options configures future gateway connections.
|
||||
type Options struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
Plaintext bool
|
||||
CACertFile string
|
||||
ServerNameOverride string
|
||||
}
|
||||
|
||||
// RedactedAPIKey returns a display-safe representation of the configured API
|
||||
// key for diagnostics and CLI output.
|
||||
func (o Options) RedactedAPIKey() string {
|
||||
return RedactAPIKey(o.APIKey)
|
||||
}
|
||||
|
||||
// RedactAPIKey hides credential material while keeping enough shape for
|
||||
// troubleshooting whether a key was supplied.
|
||||
func RedactAPIKey(apiKey string) string {
|
||||
if apiKey == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if len(apiKey) <= 8 {
|
||||
return "<redacted>"
|
||||
}
|
||||
|
||||
prefix, suffix := apiKey[:4], apiKey[len(apiKey)-4:]
|
||||
return prefix + strings.Repeat("*", len(apiKey)-8) + suffix
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package mxgateway
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRedactAPIKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiKey string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", apiKey: "", want: ""},
|
||||
{name: "short", apiKey: "mxgw_1", want: "<redacted>"},
|
||||
{name: "long", apiKey: "mxgw_key_secret", want: "mxgw*******cret"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := RedactAPIKey(tt.apiKey); got != tt.want {
|
||||
t.Fatalf("RedactAPIKey() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mxgateway
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestGeneratedGoldenFixturesParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
msg proto.Message
|
||||
}{
|
||||
{
|
||||
name: "open session reply",
|
||||
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"),
|
||||
msg: &pb.OpenSessionReply{},
|
||||
},
|
||||
{
|
||||
name: "register command request",
|
||||
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "register-command-request.json"),
|
||||
msg: &pb.MxCommandRequest{},
|
||||
},
|
||||
{
|
||||
name: "on data change event",
|
||||
path: filepath.Join("..", "..", "proto", "fixtures", "golden", "on-data-change-event.json"),
|
||||
msg: &pb.MxEvent{},
|
||||
},
|
||||
}
|
||||
|
||||
unmarshal := protojson.UnmarshalOptions{DiscardUnknown: false}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := os.ReadFile(tt.path)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
if err := unmarshal.Unmarshal(data, tt.msg); err != nil {
|
||||
t.Fatalf("parse fixture: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSessionFixtureProtocolVersions(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "proto", "fixtures", "golden", "open-session-reply.ok.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
|
||||
var reply pb.OpenSessionReply
|
||||
if err := protojson.Unmarshal(data, &reply); err != nil {
|
||||
t.Fatalf("parse fixture: %v", err)
|
||||
}
|
||||
|
||||
if reply.GetGatewayProtocolVersion() != GatewayProtocolVersion {
|
||||
t.Fatalf("gateway protocol = %d, want %d", reply.GetGatewayProtocolVersion(), GatewayProtocolVersion)
|
||||
}
|
||||
|
||||
if reply.GetWorkerProtocolVersion() != WorkerProtocolVersion {
|
||||
t.Fatalf("worker protocol = %d, want %d", reply.GetWorkerProtocolVersion(), WorkerProtocolVersion)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package mxgateway
|
||||
|
||||
const (
|
||||
// ClientVersion identifies this Go client scaffold before package releases
|
||||
// assign semantic versions.
|
||||
ClientVersion = "0.1.0-dev"
|
||||
|
||||
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
|
||||
// in the shared .NET contracts.
|
||||
GatewayProtocolVersion uint32 = 1
|
||||
|
||||
// WorkerProtocolVersion matches GatewayContractInfo.WorkerProtocolVersion
|
||||
// and is exposed for fake-worker and parity tests.
|
||||
WorkerProtocolVersion uint32 = 1
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cases": [
|
||||
{
|
||||
"id": "missing-api-key",
|
||||
"grpcStatusCode": "UNAUTHENTICATED",
|
||||
"clientErrorCategory": "AuthenticationError",
|
||||
"inputMetadata": {
|
||||
"authorization": ""
|
||||
},
|
||||
"expectedRedactedOutput": "authentication failed: missing bearer token",
|
||||
"retryableWithoutCredentialChange": false
|
||||
},
|
||||
{
|
||||
"id": "invalid-api-key",
|
||||
"grpcStatusCode": "UNAUTHENTICATED",
|
||||
"clientErrorCategory": "AuthenticationError",
|
||||
"inputMetadata": {
|
||||
"authorization": "Bearer <redacted>"
|
||||
},
|
||||
"expectedRedactedOutput": "authentication failed: invalid API key <redacted>",
|
||||
"retryableWithoutCredentialChange": false
|
||||
},
|
||||
{
|
||||
"id": "missing-write-scope",
|
||||
"grpcStatusCode": "PERMISSION_DENIED",
|
||||
"clientErrorCategory": "AuthorizationError",
|
||||
"inputMetadata": {
|
||||
"authorization": "Bearer <redacted>"
|
||||
},
|
||||
"requiredScope": "mxaccess.write",
|
||||
"expectedRedactedOutput": "authorization failed: missing scope mxaccess.write",
|
||||
"retryableWithoutCredentialChange": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"correlationId": "gateway-correlation-register-1",
|
||||
"kind": "MX_COMMAND_KIND_REGISTER",
|
||||
"protocolStatus": {
|
||||
"code": "PROTOCOL_STATUS_CODE_OK",
|
||||
"message": "Register completed."
|
||||
},
|
||||
"hresult": 0,
|
||||
"returnValue": {
|
||||
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||
"variantType": "VT_I4",
|
||||
"int32Value": 12
|
||||
},
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "OK"
|
||||
}
|
||||
],
|
||||
"diagnosticMessage": "COM Register returned server handle 12.",
|
||||
"register": {
|
||||
"serverHandle": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"correlationId": "gateway-correlation-write-1",
|
||||
"kind": "MX_COMMAND_KIND_WRITE",
|
||||
"protocolStatus": {
|
||||
"code": "PROTOCOL_STATUS_CODE_MXACCESS_FAILURE",
|
||||
"message": "MXAccess rejected the write."
|
||||
},
|
||||
"hresult": -2147220992,
|
||||
"returnValue": {
|
||||
"dataType": "MX_DATA_TYPE_NO_DATA",
|
||||
"variantType": "VT_EMPTY",
|
||||
"isNull": true,
|
||||
"rawDiagnostic": "MXAccess returned no value for the failed write.",
|
||||
"rawDataType": 2
|
||||
},
|
||||
"statuses": [
|
||||
{
|
||||
"success": 0,
|
||||
"category": "MX_STATUS_CATEGORY_SECURITY_ERROR",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 321,
|
||||
"rawCategory": 8,
|
||||
"rawDetectedBy": 3,
|
||||
"diagnosticText": "Write denied by provider security."
|
||||
},
|
||||
{
|
||||
"success": 0,
|
||||
"category": "MX_STATUS_CATEGORY_OPERATIONAL_ERROR",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX",
|
||||
"detail": 902,
|
||||
"rawCategory": 7,
|
||||
"rawDetectedBy": 5,
|
||||
"diagnosticText": "Provider rejected the item state."
|
||||
}
|
||||
],
|
||||
"diagnosticMessage": "Fixture preserves a data-bearing MXAccess failure reply with HRESULT and status array."
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"description": "Ordered event stream sample for one worker-backed session.",
|
||||
"events": [
|
||||
{
|
||||
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
|
||||
"sessionId": "session-fixture",
|
||||
"serverHandle": 12,
|
||||
"itemHandle": 34,
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||
"variantType": "VT_I4",
|
||||
"int32Value": 123
|
||||
},
|
||||
"quality": 192,
|
||||
"sourceTimestamp": "2026-01-01T00:00:00Z",
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "OK"
|
||||
}
|
||||
],
|
||||
"workerSequence": "1",
|
||||
"workerTimestamp": "2026-01-01T00:00:00.010Z",
|
||||
"gatewayReceiveTimestamp": "2026-01-01T00:00:00.015Z",
|
||||
"onDataChange": {}
|
||||
},
|
||||
{
|
||||
"family": "MX_EVENT_FAMILY_ON_WRITE_COMPLETE",
|
||||
"sessionId": "session-fixture",
|
||||
"serverHandle": 12,
|
||||
"itemHandle": 34,
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_DOUBLE",
|
||||
"variantType": "VT_R8",
|
||||
"doubleValue": 45.5
|
||||
},
|
||||
"quality": 192,
|
||||
"sourceTimestamp": "2026-01-01T00:00:01Z",
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "Write complete."
|
||||
}
|
||||
],
|
||||
"workerSequence": "2",
|
||||
"workerTimestamp": "2026-01-01T00:00:01.010Z",
|
||||
"gatewayReceiveTimestamp": "2026-01-01T00:00:01.015Z",
|
||||
"hresult": 0,
|
||||
"onWriteComplete": {}
|
||||
},
|
||||
{
|
||||
"family": "MX_EVENT_FAMILY_OPERATION_COMPLETE",
|
||||
"sessionId": "session-fixture",
|
||||
"serverHandle": 12,
|
||||
"itemHandle": 34,
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_STRING",
|
||||
"variantType": "VT_BSTR",
|
||||
"stringValue": "operation-complete"
|
||||
},
|
||||
"quality": 192,
|
||||
"sourceTimestamp": "2026-01-01T00:00:02Z",
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_NMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "Operation complete."
|
||||
}
|
||||
],
|
||||
"workerSequence": "3",
|
||||
"workerTimestamp": "2026-01-01T00:00:02.010Z",
|
||||
"gatewayReceiveTimestamp": "2026-01-01T00:00:02.015Z",
|
||||
"operationComplete": {}
|
||||
},
|
||||
{
|
||||
"family": "MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE",
|
||||
"sessionId": "session-fixture",
|
||||
"serverHandle": 12,
|
||||
"itemHandle": 34,
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_FLOAT",
|
||||
"arrayValue": {
|
||||
"elementDataType": "MX_DATA_TYPE_FLOAT",
|
||||
"variantType": "VT_ARRAY|VT_R4",
|
||||
"dimensions": [
|
||||
2
|
||||
],
|
||||
"floatValues": {
|
||||
"values": [
|
||||
1.5,
|
||||
2.5
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality": 192,
|
||||
"sourceTimestamp": "2026-01-01T00:00:03Z",
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "Buffered data delivered."
|
||||
}
|
||||
],
|
||||
"workerSequence": "4",
|
||||
"workerTimestamp": "2026-01-01T00:00:03.010Z",
|
||||
"gatewayReceiveTimestamp": "2026-01-01T00:00:03.015Z",
|
||||
"onBufferedDataChange": {
|
||||
"dataType": "MX_DATA_TYPE_FLOAT",
|
||||
"qualityValues": {
|
||||
"elementDataType": "MX_DATA_TYPE_INTEGER",
|
||||
"variantType": "VT_ARRAY|VT_I4",
|
||||
"dimensions": [
|
||||
2
|
||||
],
|
||||
"int32Values": {
|
||||
"values": [
|
||||
192,
|
||||
192
|
||||
]
|
||||
}
|
||||
},
|
||||
"timestampValues": {
|
||||
"elementDataType": "MX_DATA_TYPE_TIME",
|
||||
"variantType": "VT_ARRAY|VT_DATE",
|
||||
"dimensions": [
|
||||
2
|
||||
],
|
||||
"timestampValues": {
|
||||
"values": [
|
||||
"2026-01-01T00:00:02Z",
|
||||
"2026-01-01T00:00:03Z"
|
||||
]
|
||||
}
|
||||
},
|
||||
"rawDataType": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"fixtureSet": "mxaccess-gateway-client-behavior",
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoInputManifest": "clients/proto/proto-inputs.json",
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "command-reply.register.ok",
|
||||
"category": "command_replies",
|
||||
"messageType": "mxaccess_gateway.v1.MxCommandReply",
|
||||
"path": "command-replies/register.ok.reply.json",
|
||||
"expectation": "Successful command replies preserve protocol status, HRESULT, return value, status arrays, and method-specific output."
|
||||
},
|
||||
{
|
||||
"id": "command-reply.write.mxaccess-failure",
|
||||
"category": "command_replies",
|
||||
"messageType": "mxaccess_gateway.v1.MxCommandReply",
|
||||
"path": "command-replies/write.mxaccess-failure.reply.json",
|
||||
"expectation": "MXAccess failures are data-bearing replies with HRESULT and status details, not transport failures."
|
||||
},
|
||||
{
|
||||
"id": "event-stream.session-ordered",
|
||||
"category": "event_streams",
|
||||
"messageType": "mxaccess_gateway.v1.MxEvent",
|
||||
"path": "event-streams/session-event-stream.json",
|
||||
"expectation": "Clients preserve per-session event order and event family bodies exactly as emitted."
|
||||
},
|
||||
{
|
||||
"id": "values.conversion-cases",
|
||||
"category": "value_conversion",
|
||||
"messageType": "mxaccess_gateway.v1.MxValue",
|
||||
"path": "values/value-conversion-cases.json",
|
||||
"expectation": "Clients expose typed projections and keep raw fallback metadata when conversion is incomplete."
|
||||
},
|
||||
{
|
||||
"id": "statuses.conversion-cases",
|
||||
"category": "status_conversion",
|
||||
"messageType": "mxaccess_gateway.v1.MxStatusProxy",
|
||||
"path": "statuses/status-conversion-cases.json",
|
||||
"expectation": "Clients preserve every MXSTATUS_PROXY field, including raw category/source values."
|
||||
},
|
||||
{
|
||||
"id": "auth.error-cases",
|
||||
"category": "auth_errors",
|
||||
"messageType": "client_behavior.v1.AuthErrorCase",
|
||||
"path": "auth/auth-error-cases.json",
|
||||
"expectation": "Clients map authentication and authorization failures distinctly and redact credentials."
|
||||
},
|
||||
{
|
||||
"id": "timeout-cancel.expected-behavior",
|
||||
"category": "timeout_cancel",
|
||||
"messageType": "client_behavior.v1.TimeoutCancelCase",
|
||||
"path": "timeout-cancel/timeout-cancel-cases.json",
|
||||
"expectation": "Client cancellation stops waiting locally but does not imply an in-flight MXAccess COM call was aborted."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cases": [
|
||||
{
|
||||
"id": "ok.responding-lmx",
|
||||
"status": {
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "security-error.requesting-lmx",
|
||||
"status": {
|
||||
"success": 0,
|
||||
"category": "MX_STATUS_CATEGORY_SECURITY_ERROR",
|
||||
"detectedBy": "MX_STATUS_SOURCE_REQUESTING_LMX",
|
||||
"detail": 401,
|
||||
"rawCategory": 8,
|
||||
"rawDetectedBy": 2,
|
||||
"diagnosticText": "Requesting LMX denied the secured operation."
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "raw-unknown-category",
|
||||
"status": {
|
||||
"success": 0,
|
||||
"category": "MX_STATUS_CATEGORY_UNKNOWN",
|
||||
"detectedBy": "MX_STATUS_SOURCE_UNKNOWN",
|
||||
"detail": 65535,
|
||||
"rawCategory": 99,
|
||||
"rawDetectedBy": 77,
|
||||
"diagnosticText": "Unknown native MXSTATUS_PROXY fields are preserved."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cases": [
|
||||
{
|
||||
"id": "unary-deadline-exceeded",
|
||||
"operation": "Invoke",
|
||||
"clientDeadline": "2s",
|
||||
"grpcStatusCode": "DEADLINE_EXCEEDED",
|
||||
"clientErrorCategory": "TimeoutError",
|
||||
"gatewayWaitBehavior": "stops_waiting_for_reply",
|
||||
"workerCommandBehavior": "continues_until_worker_reply_or_worker_fault",
|
||||
"sessionExpectation": "session_state_is_unknown_until_follow_up_status_or_close",
|
||||
"expectedClientAction": "issue GetSessionState or CloseSession before reusing handles"
|
||||
},
|
||||
{
|
||||
"id": "stream-cancel",
|
||||
"operation": "StreamEvents",
|
||||
"clientDeadline": "5s",
|
||||
"grpcStatusCode": "CANCELLED",
|
||||
"clientErrorCategory": "CancelledError",
|
||||
"gatewayWaitBehavior": "stops_streaming_to_that_call",
|
||||
"workerCommandBehavior": "does_not_cancel_worker_session",
|
||||
"sessionExpectation": "session_remains_ready_if_worker_stays_healthy",
|
||||
"expectedClientAction": "open a new StreamEvents call with the last observed worker sequence"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"cases": [
|
||||
{
|
||||
"id": "bool.true",
|
||||
"expectedKind": "boolValue",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_BOOLEAN",
|
||||
"variantType": "VT_BOOL",
|
||||
"boolValue": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "int64.large",
|
||||
"expectedKind": "int64Value",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||
"variantType": "VT_I8",
|
||||
"int64Value": "9223372036854770000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "timestamp.utc",
|
||||
"expectedKind": "timestampValue",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_TIME",
|
||||
"variantType": "VT_DATE",
|
||||
"timestampValue": "2026-01-01T00:00:04Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "string-array",
|
||||
"expectedKind": "arrayValue",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_STRING",
|
||||
"arrayValue": {
|
||||
"elementDataType": "MX_DATA_TYPE_STRING",
|
||||
"variantType": "VT_ARRAY|VT_BSTR",
|
||||
"dimensions": [
|
||||
2
|
||||
],
|
||||
"stringValues": {
|
||||
"values": [
|
||||
"alpha",
|
||||
"beta"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "raw-fallback.variant",
|
||||
"expectedKind": "rawValue",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_UNKNOWN",
|
||||
"variantType": "VT_RECORD",
|
||||
"rawDiagnostic": "No lossless typed projection exists for this VARIANT.",
|
||||
"rawDataType": 32767,
|
||||
"rawValue": "AQIDBAU="
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "raw-array-fallback",
|
||||
"expectedKind": "arrayValue",
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_UNKNOWN",
|
||||
"arrayValue": {
|
||||
"elementDataType": "MX_DATA_TYPE_UNKNOWN",
|
||||
"variantType": "VT_ARRAY|VT_VARIANT",
|
||||
"dimensions": [
|
||||
2
|
||||
],
|
||||
"rawDiagnostic": "Array elements contain mixed VARIANT types.",
|
||||
"rawElementDataType": 32767,
|
||||
"rawValues": {
|
||||
"values": [
|
||||
"AAE=",
|
||||
"AgM="
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"family": "MX_EVENT_FAMILY_ON_DATA_CHANGE",
|
||||
"sessionId": "session-fixture",
|
||||
"serverHandle": 12,
|
||||
"itemHandle": 34,
|
||||
"value": {
|
||||
"dataType": "MX_DATA_TYPE_INTEGER",
|
||||
"variantType": "VT_I4",
|
||||
"int32Value": 123
|
||||
},
|
||||
"quality": 192,
|
||||
"sourceTimestamp": "2026-01-01T00:00:00Z",
|
||||
"statuses": [
|
||||
{
|
||||
"success": 1,
|
||||
"category": "MX_STATUS_CATEGORY_OK",
|
||||
"detectedBy": "MX_STATUS_SOURCE_RESPONDING_LMX",
|
||||
"detail": 0,
|
||||
"rawCategory": 0,
|
||||
"rawDetectedBy": 0,
|
||||
"diagnosticText": "OK"
|
||||
}
|
||||
],
|
||||
"workerSequence": "1",
|
||||
"workerTimestamp": "2026-01-01T00:00:00.010Z",
|
||||
"gatewayReceiveTimestamp": "2026-01-01T00:00:00.015Z",
|
||||
"onDataChange": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"backendName": "mxaccess-worker",
|
||||
"workerProcessId": 1234,
|
||||
"workerProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 1,
|
||||
"capabilities": [
|
||||
"unary-open-session",
|
||||
"unary-close-session",
|
||||
"unary-invoke",
|
||||
"server-stream-events"
|
||||
],
|
||||
"defaultCommandTimeout": "30s",
|
||||
"protocolStatus": {
|
||||
"code": "PROTOCOL_STATUS_CODE_OK",
|
||||
"message": "Session opened."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionId": "session-fixture",
|
||||
"clientCorrelationId": "fixture-register-1",
|
||||
"command": {
|
||||
"kind": "MX_COMMAND_KIND_REGISTER",
|
||||
"register": {
|
||||
"clientName": "fixture-client"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoRoot": "src/MxGateway.Contracts/Protos",
|
||||
"sourceFiles": [
|
||||
{
|
||||
"path": "mxaccess_gateway.proto",
|
||||
"role": "public_gateway"
|
||||
},
|
||||
{
|
||||
"path": "mxaccess_worker.proto",
|
||||
"role": "gateway_worker_ipc"
|
||||
}
|
||||
],
|
||||
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
|
||||
"fixtureRoot": "clients/proto/fixtures/golden",
|
||||
"behaviorFixtureRoot": "clients/proto/fixtures/behavior",
|
||||
"generatedOutputs": {
|
||||
"dotnet": "clients/dotnet/generated",
|
||||
"go": "clients/go/internal/generated",
|
||||
"rust": "clients/rust/src/generated",
|
||||
"python": "clients/python/src/mxgateway/generated",
|
||||
"java": "clients/java/src/main/generated"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Client Behavior Fixtures
|
||||
|
||||
Client behavior fixtures define the shared expectations used by the official
|
||||
.NET, Go, Rust, Python, and Java clients. They keep wrapper behavior aligned
|
||||
while each language exposes idiomatic APIs over the same protobuf contract.
|
||||
|
||||
## Fixture Set
|
||||
|
||||
The fixture manifest is `clients/proto/fixtures/behavior/manifest.json`.
|
||||
`clients/proto/proto-inputs.json` references the fixture root through
|
||||
`behaviorFixtureRoot` so generators and client test projects can discover the
|
||||
same files they use for descriptor inputs.
|
||||
|
||||
The fixture set contains:
|
||||
|
||||
- command reply protobuf JSON,
|
||||
- ordered event stream protobuf JSON samples,
|
||||
- `MxValue` conversion case sets,
|
||||
- `MxStatusProxy` conversion case sets,
|
||||
- authentication and authorization error expectations,
|
||||
- timeout and cancellation behavior expectations.
|
||||
|
||||
Protobuf message fixtures use protobuf JSON field names and enum values. Files
|
||||
that describe client wrapper behavior use explicit JSON fields instead of a
|
||||
proto message because those expectations apply above the generated transport
|
||||
types.
|
||||
|
||||
## Command Replies
|
||||
|
||||
Command reply fixtures live in
|
||||
`clients/proto/fixtures/behavior/command-replies/`. They parse as
|
||||
`mxaccess_gateway.v1.MxCommandReply`.
|
||||
|
||||
Clients use these fixtures to verify that successful and failed MXAccess
|
||||
commands both carry the full reply details:
|
||||
|
||||
- `protocolStatus`,
|
||||
- `hresult`,
|
||||
- `returnValue`,
|
||||
- repeated `statuses`,
|
||||
- method-specific reply payloads when MXAccess returns out parameters.
|
||||
|
||||
MXAccess failures remain command replies when the gateway reached the worker and
|
||||
the worker captured HRESULT or `MXSTATUS_PROXY` details. Client wrappers should
|
||||
map those replies to rich command errors without discarding the raw reply.
|
||||
|
||||
## Event Streams
|
||||
|
||||
Event stream fixtures live in
|
||||
`clients/proto/fixtures/behavior/event-streams/`. Each file contains an ordered
|
||||
`events` array whose entries parse as `mxaccess_gateway.v1.MxEvent`.
|
||||
|
||||
Clients use these fixtures to verify that stream helpers preserve
|
||||
`workerSequence` order and expose each native event family:
|
||||
|
||||
- `OnDataChange`,
|
||||
- `OnWriteComplete`,
|
||||
- `OperationComplete`,
|
||||
- `OnBufferedDataChange`.
|
||||
|
||||
Wrappers must not reorder, coalesce, or drop events while reading the fixture.
|
||||
|
||||
## Value And Status Conversion
|
||||
|
||||
Value fixtures live in `clients/proto/fixtures/behavior/values/`. Each case
|
||||
contains a `value` object that parses as `mxaccess_gateway.v1.MxValue`.
|
||||
|
||||
Status fixtures live in `clients/proto/fixtures/behavior/statuses/`. Each case
|
||||
contains a `status` object that parses as
|
||||
`mxaccess_gateway.v1.MxStatusProxy`.
|
||||
|
||||
Clients use these fixtures to verify typed projections and raw fallback
|
||||
behavior. A language helper may expose native booleans, integers, strings,
|
||||
arrays, and timestamps, but it must keep `rawDiagnostic`, raw data type fields,
|
||||
and raw byte payloads accessible when conversion is incomplete.
|
||||
|
||||
## Auth, Timeout, And Cancel Behavior
|
||||
|
||||
Authentication fixtures live in `clients/proto/fixtures/behavior/auth/`. They
|
||||
separate `UNAUTHENTICATED` from `PERMISSION_DENIED` so clients map missing or
|
||||
invalid credentials differently from missing scopes. Expected output strings
|
||||
contain only redacted credentials.
|
||||
|
||||
Timeout and cancellation fixtures live in
|
||||
`clients/proto/fixtures/behavior/timeout-cancel/`. They document that canceling
|
||||
or timing out a client call stops the client from waiting, but it does not abort
|
||||
an in-flight MXAccess COM call on the worker STA. Clients should follow up with
|
||||
`GetSessionState` or `CloseSession` before reusing handles after an uncertain
|
||||
command timeout.
|
||||
|
||||
## Validation
|
||||
|
||||
Run the fixture validation tests after changing the behavior fixture set:
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File scripts/validate-client-behavior-fixtures.ps1
|
||||
```
|
||||
|
||||
The script runs the focused C# contract tests that parse all protobuf JSON
|
||||
fixtures and validate deterministic wrapper expectation files.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Proto Generation](./client-proto-generation.md)
|
||||
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||
- [Protobuf Contracts](./Contracts.md)
|
||||
@@ -0,0 +1,59 @@
|
||||
# Protobuf Contracts
|
||||
|
||||
The contracts project contains the public gRPC API and the gateway-to-worker
|
||||
IPC messages. The `.proto` files are the source of truth; generated C# files are
|
||||
recreated by the contracts project build.
|
||||
|
||||
## Files
|
||||
|
||||
`src/MxGateway.Contracts/Protos/mxaccess_gateway.proto` defines the public
|
||||
`MxAccessGateway` gRPC service, command payloads, command replies, event DTOs,
|
||||
`MxValue`, `MxArray`, and `MxStatusProxy`.
|
||||
|
||||
`src/MxGateway.Contracts/Protos/mxaccess_worker.proto` defines the named-pipe
|
||||
worker IPC envelope and control messages. It imports
|
||||
`mxaccess_gateway.proto` so the worker and gateway use the same command, reply,
|
||||
event, value, and status shapes.
|
||||
|
||||
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
|
||||
hand-edit generated files.
|
||||
|
||||
Client generation inputs are published through
|
||||
`clients/proto/proto-inputs.json` and the descriptor set under
|
||||
`clients/proto/descriptors/`. See
|
||||
[Client Proto Generation](./client-proto-generation.md) for language-specific
|
||||
generation inputs, output directories, and golden protobuf JSON fixtures.
|
||||
|
||||
## Generation
|
||||
|
||||
Run the contracts build to regenerate C# protobuf and gRPC code:
|
||||
|
||||
```bash
|
||||
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
||||
```
|
||||
|
||||
Run the focused contract tests after changing either `.proto` file:
|
||||
|
||||
```bash
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter ProtobufContractRoundTripTests
|
||||
```
|
||||
|
||||
The full solution build also regenerates the C# contracts before compiling
|
||||
gateway and test projects:
|
||||
|
||||
```bash
|
||||
dotnet build src/MxGateway.sln
|
||||
```
|
||||
|
||||
Regenerate the client descriptor after changing either `.proto` file:
|
||||
|
||||
```bash
|
||||
powershell -ExecutionPolicy Bypass -File scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Proto Generation](./client-proto-generation.md)
|
||||
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
||||
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
@@ -0,0 +1,61 @@
|
||||
# Gateway Testing
|
||||
|
||||
Gateway tests run without installed MXAccess by using fake workers, fake
|
||||
transports, and in-process gRPC service fakes. Live MXAccess verification belongs
|
||||
in opt-in integration tests because it depends on installed COM components and
|
||||
provider state.
|
||||
|
||||
## Fake Worker Harness
|
||||
|
||||
`FakeWorkerHarness` in `src/MxGateway.Tests/Gateway/Workers/Fakes/` provides an
|
||||
in-process worker side for named-pipe IPC tests. It uses the same
|
||||
`WorkerFrameReader`, `WorkerFrameWriter`, and `WorkerEnvelope` contract as the
|
||||
gateway so tests exercise real frame validation and worker-client state changes.
|
||||
|
||||
Use the harness when a gateway or session test needs worker behavior without
|
||||
starting `MxGateway.Worker.exe` or loading MXAccess COM. The harness scripts:
|
||||
|
||||
- `WorkerHello` and `WorkerReady` startup,
|
||||
- command replies with matching correlation ids,
|
||||
- ordered `WorkerEvent` frames,
|
||||
- `WorkerHeartbeat` frames,
|
||||
- `WorkerFault` frames,
|
||||
- shutdown acknowledgements,
|
||||
- malformed protobuf payloads and oversized frame headers,
|
||||
- slow or hung workers by withholding a reply.
|
||||
|
||||
Session-level tests can connect the harness to the pipe created by
|
||||
`SessionWorkerClientFactory` with `ConnectToGatewayPipeAsync`. Lower-level
|
||||
`WorkerClient` tests can use `CreateConnectedPairAsync` to create both pipe ends
|
||||
inside the test.
|
||||
|
||||
`GatewayEndToEndFakeWorkerSmokeTests` composes the real gRPC service,
|
||||
`SessionManager`, `SessionWorkerClientFactory`, `WorkerClient`, and
|
||||
`EventStreamService` with a scripted fake worker launcher. The smoke test covers
|
||||
`OpenSession`, `Register`, `AddItem`, `Advise`, one streamed `OnDataChange`
|
||||
event, and `CloseSession` without loading MXAccess COM.
|
||||
|
||||
## Focused Commands
|
||||
|
||||
Run the fake worker tests after changing gateway worker IPC, session startup, or
|
||||
event streaming behavior:
|
||||
|
||||
```bash
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~FakeWorkerHarnessTests
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~SessionWorkerClientFactoryFakeWorkerTests
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~GatewayEndToEndFakeWorkerSmokeTests
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter FullyQualifiedName~WorkerClientTests
|
||||
dotnet test src/MxGateway.Worker.Tests/MxGateway.Worker.Tests.csproj -p:Platform=x86 --filter FullyQualifiedName~WorkerPipeSessionTests
|
||||
```
|
||||
|
||||
Run the gateway test project after shared gateway test infrastructure changes:
|
||||
|
||||
```bash
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Design](./gateway-process-design.md)
|
||||
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
|
||||
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Worker Frame Protocol
|
||||
|
||||
The gateway uses the worker frame protocol to move `WorkerEnvelope` protobuf
|
||||
messages over a bidirectional named pipe. The frame layer is deliberately small:
|
||||
it handles message boundaries, size limits, protobuf parsing, and envelope
|
||||
validation before higher-level worker client code routes commands, replies,
|
||||
events, and faults.
|
||||
|
||||
## Frame Format
|
||||
|
||||
Each frame starts with a four-byte little-endian unsigned payload length,
|
||||
followed by the serialized `WorkerEnvelope` payload:
|
||||
|
||||
```text
|
||||
uint32 little-endian payload_length
|
||||
payload_length bytes protobuf WorkerEnvelope
|
||||
```
|
||||
|
||||
The reader rejects zero-length payloads and payloads larger than the configured
|
||||
maximum before allocating the payload buffer. The default maximum is 16 MiB,
|
||||
matching the gateway process design.
|
||||
|
||||
## Envelope Validation
|
||||
|
||||
`WorkerFrameReader` and `WorkerFrameWriter` validate each envelope against the
|
||||
owning session before returning or writing it:
|
||||
|
||||
- `protocol_version` must match the configured worker protocol version,
|
||||
- `session_id` must match the owning gateway session,
|
||||
- the envelope must contain one typed `body` value.
|
||||
|
||||
Protocol violations throw `WorkerFrameProtocolException` with a
|
||||
`WorkerFrameProtocolErrorCode` so callers can distinguish malformed frames,
|
||||
oversized frames, protocol version mismatches, and session mismatches.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused tests after changing the frame protocol:
|
||||
|
||||
```bash
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter WorkerFrameProtocolTests
|
||||
```
|
||||
|
||||
Run the gateway build because the frame protocol is part of
|
||||
`MxGateway.Server`:
|
||||
|
||||
```bash
|
||||
dotnet build src/MxGateway.Server/MxGateway.Server.csproj
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
||||
- [Protobuf Contracts](./Contracts.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Worker Process Launcher
|
||||
|
||||
The gateway uses `WorkerProcessLauncher` to validate and start one worker
|
||||
process for a gateway session. The launcher owns process start semantics only;
|
||||
pipe handshaking and `WorkerReady` validation remain part of the worker client
|
||||
startup path.
|
||||
|
||||
## Launch Inputs
|
||||
|
||||
`WorkerProcessLaunchRequest` carries the per-session bootstrap values:
|
||||
|
||||
- `SessionId`,
|
||||
- `PipeName`,
|
||||
- `ProtocolVersion`,
|
||||
- `Nonce`,
|
||||
- optional `PipeReservation` cleanup handle.
|
||||
|
||||
The launcher passes `SessionId`, `PipeName`, and `ProtocolVersion` as command
|
||||
line arguments:
|
||||
|
||||
```text
|
||||
--session-id <sessionId> --pipe-name <pipeName> --protocol-version <version>
|
||||
```
|
||||
|
||||
The launcher sets the nonce through the `MXGATEWAY_WORKER_NONCE` environment
|
||||
variable. The nonce is not included in `WorkerProcessCommandLine` so logs and
|
||||
diagnostics can report the launch command without exposing the secret.
|
||||
|
||||
## Validation And Cleanup
|
||||
|
||||
Before starting the process, the launcher validates that the configured worker
|
||||
path exists, has a `.exe` extension, contains a valid Windows Portable
|
||||
Executable header, and matches the configured `RequiredArchitecture`.
|
||||
|
||||
After the process starts, `IWorkerStartupProbe` waits for startup readiness.
|
||||
The default probe only verifies that the worker did not exit immediately. The
|
||||
worker client replaces this probe when pipe connection, hello, and
|
||||
`WorkerReady` handling are implemented.
|
||||
|
||||
If startup fails or exceeds `WorkerOptions.StartupTimeoutSeconds`, the launcher
|
||||
kills the worker process tree, disposes the process handle, disposes the
|
||||
optional pipe reservation, records a worker kill metric, and reports a
|
||||
`WorkerProcessLaunchException`.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the focused launcher tests after changing process launch behavior:
|
||||
|
||||
```bash
|
||||
dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj --filter WorkerProcessLauncherTests
|
||||
```
|
||||
|
||||
Run the gateway build because the launcher is part of `MxGateway.Server`:
|
||||
|
||||
```bash
|
||||
dotnet build src/MxGateway.Server/MxGateway.Server.csproj
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
||||
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
|
||||
@@ -26,6 +26,12 @@ Language-specific plans:
|
||||
- `docs/clients-python-design.md`
|
||||
- `docs/clients-java-design.md`
|
||||
|
||||
Shared generation inputs:
|
||||
|
||||
- `docs/client-proto-generation.md`
|
||||
- `docs/ClientBehaviorFixtures.md`
|
||||
- `clients/proto/proto-inputs.json`
|
||||
|
||||
Language style guides:
|
||||
|
||||
| Client | Style guide |
|
||||
@@ -305,6 +311,11 @@ CLI output should support JSON for automated tests.
|
||||
Unit tests must run without a live gateway. Use fake gRPC services, mock
|
||||
transports, or generated test servers depending on language.
|
||||
|
||||
Shared behavior fixtures live in `clients/proto/fixtures/behavior`. Every
|
||||
client should include tests that load the fixture manifest and verify wrapper
|
||||
behavior against the common command reply, event stream, value conversion,
|
||||
status conversion, auth error, and timeout/cancel cases.
|
||||
|
||||
Required unit test areas:
|
||||
|
||||
- options parsing,
|
||||
@@ -365,6 +376,16 @@ examples/
|
||||
Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`.
|
||||
Do not hand-edit generated code.
|
||||
|
||||
The stable client proto manifest defines the generated-code directories:
|
||||
|
||||
```text
|
||||
clients/dotnet/generated
|
||||
clients/go/internal/generated
|
||||
clients/rust/src/generated
|
||||
clients/python/src/mxgateway/generated
|
||||
clients/java/src/main/generated
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
All clients should expose:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# Client Proto Generation
|
||||
|
||||
This document defines the stable protobuf inputs that official clients use to
|
||||
generate language-specific gRPC bindings. The checked-in `.proto` files remain
|
||||
the source of truth so clients do not drift from the gateway and worker
|
||||
contracts.
|
||||
|
||||
## Stable Inputs
|
||||
|
||||
The stable client input manifest is `clients/proto/proto-inputs.json`. It
|
||||
records:
|
||||
|
||||
- the public gateway protocol version,
|
||||
- the worker IPC protocol version,
|
||||
- the protobuf import root,
|
||||
- the public and worker source files,
|
||||
- the descriptor set path,
|
||||
- golden fixture locations,
|
||||
- behavior fixture locations,
|
||||
- generated-code output directories for each planned client.
|
||||
|
||||
The source files listed by the manifest are:
|
||||
|
||||
- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`
|
||||
- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto`
|
||||
|
||||
`mxaccess_gateway.proto` defines the public gRPC service and shared DTOs.
|
||||
`mxaccess_worker.proto` is included in the descriptor because worker-aware
|
||||
tests and fake-worker clients need the same command, reply, event, value, and
|
||||
status shapes.
|
||||
|
||||
## Protocol Version
|
||||
|
||||
`GatewayContractInfo.GatewayProtocolVersion` is the public gateway protocol
|
||||
version. `OpenSessionReply.gateway_protocol_version` returns the same value so
|
||||
clients can compare their generated bindings against the gateway before issuing
|
||||
MXAccess commands.
|
||||
|
||||
`GatewayContractInfo.WorkerProtocolVersion` remains the gateway-to-worker IPC
|
||||
protocol version. It is also present in `OpenSessionReply` because parity
|
||||
fixtures and fake-worker tests need to know the worker contract used by the
|
||||
session.
|
||||
|
||||
## Descriptor Publishing
|
||||
|
||||
Run this command after changing either source `.proto` file or the client proto
|
||||
manifest:
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1
|
||||
```
|
||||
|
||||
The script writes
|
||||
`clients/proto/descriptors/mxaccessgw-client-v1.protoset` with imports and
|
||||
source information included. The descriptor is a generated artifact; do not edit
|
||||
it by hand.
|
||||
|
||||
Use the check mode in CI or before committing:
|
||||
|
||||
```powershell
|
||||
scripts/publish-client-proto-inputs.ps1 -Check
|
||||
```
|
||||
|
||||
`-Check` rebuilds the descriptor in a temporary path and fails when the checked
|
||||
in descriptor is stale.
|
||||
|
||||
## Output Directories
|
||||
|
||||
The manifest declares these generated-code directories:
|
||||
|
||||
| Client | Directory |
|
||||
|--------|-----------|
|
||||
| .NET | `clients/dotnet/generated` |
|
||||
| Go | `clients/go/internal/generated` |
|
||||
| Rust | `clients/rust/src/generated` |
|
||||
| Python | `clients/python/src/mxgateway/generated` |
|
||||
| Java | `clients/java/src/main/generated` |
|
||||
|
||||
Only generator output belongs in these directories. Handwritten client wrappers
|
||||
belong in the language-specific source trees created by the client scaffold
|
||||
issues.
|
||||
|
||||
## Language Generation Inputs
|
||||
|
||||
All generators use `src/MxGateway.Contracts/Protos` as the protobuf import
|
||||
root. The checked-in descriptor is available when a language build prefers a
|
||||
descriptor input, but the `.proto` files remain canonical.
|
||||
|
||||
.NET generation currently runs through the contracts project:
|
||||
|
||||
```powershell
|
||||
dotnet build src/MxGateway.Contracts/MxGateway.Contracts.csproj
|
||||
```
|
||||
|
||||
Future .NET client projects may either reference `MxGateway.Contracts` or
|
||||
generate client-local files into `clients/dotnet/generated` with `Grpc.Tools`.
|
||||
|
||||
Go clients should generate `mxaccess_gateway.proto` and
|
||||
`mxaccess_worker.proto` into `clients/go/internal/generated` with
|
||||
`protoc-gen-go` and `protoc-gen-go-grpc`. Keep generated packages internal
|
||||
unless the wrapper API intentionally exposes raw protobuf messages.
|
||||
|
||||
The Go scaffold provides a repo-local generation script:
|
||||
|
||||
```powershell
|
||||
clients/go/generate-proto.ps1
|
||||
```
|
||||
|
||||
The script maps both proto files into the internal Go package
|
||||
`gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated` because
|
||||
the source `.proto` files do not carry Go-specific `go_package` options. This
|
||||
keeps language-specific packaging outside the public contract files.
|
||||
|
||||
Rust clients should use `tonic-build` or the selected protobuf generator from
|
||||
the Rust client build script, with generated modules placed under
|
||||
`clients/rust/src/generated` or included from the build output according to the
|
||||
client crate design.
|
||||
|
||||
Python clients should use `grpc_tools.protoc` and write generated modules under
|
||||
`clients/python/src/mxgateway/generated` so imports stay separate from
|
||||
handwritten async wrappers.
|
||||
|
||||
Java clients should use the Gradle protobuf plugin and write generated sources
|
||||
under `clients/java/src/main/generated`. The Java client scaffold owns the
|
||||
Gradle plugin versions and source-set wiring.
|
||||
|
||||
## Golden Fixtures
|
||||
|
||||
Golden protobuf JSON fixtures live in `clients/proto/fixtures/golden`. They
|
||||
exercise payloads that every language client must parse:
|
||||
|
||||
- `open-session-reply.ok.json`
|
||||
- `register-command-request.json`
|
||||
- `on-data-change-event.json`
|
||||
|
||||
The fixtures use protobuf JSON field names and enum values. Contract tests parse
|
||||
them with the generated C# types so schema drift is caught before client
|
||||
generation work starts.
|
||||
|
||||
## Behavior Fixtures
|
||||
|
||||
Cross-language behavior fixtures live in
|
||||
`clients/proto/fixtures/behavior`. The manifest
|
||||
`clients/proto/fixtures/behavior/manifest.json` lists command replies, ordered
|
||||
event stream samples, value conversion cases, status conversion cases, auth
|
||||
error expectations, and timeout/cancel expectations.
|
||||
|
||||
The behavior fixtures let each generated client wrapper test the same
|
||||
expectations without a live gateway. Protobuf message fixtures parse with the
|
||||
generated types. Auth and timeout/cancel files describe wrapper behavior above
|
||||
the generated transport layer, including credential redaction and the rule that
|
||||
client cancellation does not abort an in-flight MXAccess COM call.
|
||||
|
||||
Run the focused validation script after changing these fixtures:
|
||||
|
||||
```powershell
|
||||
scripts/validate-client-behavior-fixtures.ps1
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Protobuf Contracts](./Contracts.md)
|
||||
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||
- [Client Behavior Fixtures](./ClientBehaviorFixtures.md)
|
||||
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
@@ -16,6 +16,7 @@ Recommended layout:
|
||||
|
||||
```text
|
||||
clients/dotnet/
|
||||
MxGateway.Client.sln
|
||||
MxGateway.Client/
|
||||
MxGateway.Client.csproj
|
||||
GatewayClient.cs
|
||||
@@ -41,6 +42,12 @@ Target framework:
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
```
|
||||
|
||||
The scaffold uses a project reference to
|
||||
`src/MxGateway.Contracts/MxGateway.Contracts.csproj` for generated protobuf and
|
||||
gRPC types. `clients/dotnet/generated` remains reserved for client-local
|
||||
generator output if the .NET client later needs to decouple from the contracts
|
||||
project.
|
||||
|
||||
Expected packages:
|
||||
|
||||
- `Grpc.Net.Client`
|
||||
|
||||
@@ -34,9 +34,13 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
|
||||
|
||||
## Hosting Model
|
||||
|
||||
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API.
|
||||
The dashboard is hosted by `MxGateway.Server` alongside the gRPC API. When
|
||||
`MxGateway:Dashboard:Enabled` is `true`, `MapGatewayDashboard()` maps the
|
||||
configured `Dashboard:PathBase` to the Blazor Server app and maps the login,
|
||||
logout, and access-denied HTTP endpoints beside it. When dashboard hosting is
|
||||
disabled, those routes are not mapped.
|
||||
|
||||
Suggested endpoint layout:
|
||||
Endpoint layout:
|
||||
|
||||
```text
|
||||
/dashboard
|
||||
@@ -45,7 +49,7 @@ Suggested endpoint layout:
|
||||
/dashboard/workers
|
||||
/dashboard/events
|
||||
/dashboard/settings
|
||||
/_blazor
|
||||
/dashboard/_blazor
|
||||
```
|
||||
|
||||
The app should redirect `/` to `/dashboard` only if the deployment wants the
|
||||
@@ -59,9 +63,10 @@ MxGateway.Server
|
||||
Components/
|
||||
App.razor
|
||||
Routes.razor
|
||||
DashboardPageBase.cs
|
||||
DashboardDisplay.cs
|
||||
Layout/
|
||||
DashboardLayout.razor
|
||||
NavMenu.razor
|
||||
Pages/
|
||||
DashboardHome.razor
|
||||
SessionsPage.razor
|
||||
@@ -69,26 +74,21 @@ MxGateway.Server
|
||||
WorkersPage.razor
|
||||
EventsPage.razor
|
||||
SettingsPage.razor
|
||||
Components/
|
||||
Shared/
|
||||
MetricCard.razor
|
||||
SessionTable.razor
|
||||
WorkerTable.razor
|
||||
EventRatePanel.razor
|
||||
StatusBadge.razor
|
||||
FaultList.razor
|
||||
Services/
|
||||
DashboardSnapshotService.cs
|
||||
DashboardUpdateHub.cs
|
||||
DashboardAuthorization.cs
|
||||
Models/
|
||||
DashboardSnapshot.cs
|
||||
SessionSummary.cs
|
||||
WorkerSummary.cs
|
||||
MetricSummary.cs
|
||||
DashboardSnapshotService.cs
|
||||
DashboardAuthorizationHandler.cs
|
||||
DashboardAuthenticator.cs
|
||||
DashboardSnapshot.cs
|
||||
DashboardSessionSummary.cs
|
||||
DashboardWorkerSummary.cs
|
||||
DashboardMetricSummary.cs
|
||||
```
|
||||
|
||||
`DashboardUpdateHub` here means an internal application update service, not a
|
||||
separate public SignalR hub unless implementation proves one is needed. Blazor
|
||||
Server already uses SignalR for UI circuits.
|
||||
Blazor Server provides the SignalR circuit for UI updates. The implementation
|
||||
does not add a separate public dashboard hub.
|
||||
|
||||
## Dashboard Data Source
|
||||
|
||||
@@ -105,6 +105,12 @@ Do not let Razor components directly mutate gateway session or worker objects.
|
||||
Create a small read-only dashboard service that projects gateway state into
|
||||
plain DTOs.
|
||||
|
||||
`GatewayMetrics.GetSnapshot()` is the metrics input for the first dashboard
|
||||
projection. It carries current session and worker gauges, command and event
|
||||
counters, queue depth, and fault totals. The dashboard reads that snapshot
|
||||
instead of reading raw `Meter` instruments because exporter configuration is an
|
||||
operations concern, not a UI dependency.
|
||||
|
||||
Suggested service:
|
||||
|
||||
```csharp
|
||||
@@ -131,7 +137,7 @@ gateway internals.
|
||||
|
||||
Use Blazor Server component state updates for real-time dashboard refresh.
|
||||
|
||||
Recommended pattern:
|
||||
Implemented pattern:
|
||||
|
||||
1. Page/component subscribes to `WatchSnapshotsAsync`.
|
||||
2. Snapshot service emits updates from a bounded channel or timer.
|
||||
@@ -141,10 +147,8 @@ Recommended pattern:
|
||||
|
||||
Default update cadence:
|
||||
|
||||
- immediate update on session create/close/fault,
|
||||
- immediate update on worker fault,
|
||||
- periodic metrics refresh every 1 second,
|
||||
- event-rate windows updated every 1 second.
|
||||
- event counters update on the next snapshot tick.
|
||||
|
||||
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
|
||||
counts and rates instead.
|
||||
@@ -251,19 +255,18 @@ Do not show API key secrets or pepper values.
|
||||
|
||||
## Authentication And Authorization
|
||||
|
||||
Dashboard access should use the same API-key authentication model as gRPC where
|
||||
Dashboard access uses the same API-key authentication model as gRPC where
|
||||
practical.
|
||||
|
||||
Recommended v1 behavior:
|
||||
Implemented v1 behavior:
|
||||
|
||||
- dashboard disabled by default unless configured,
|
||||
- when enabled, require API key auth,
|
||||
- require `admin` scope for dashboard access,
|
||||
- accept API key through a secure cookie established by a simple login form, or
|
||||
through reverse-proxy/header configuration for local deployments,
|
||||
- do not put API keys in query strings.
|
||||
- accept API key through a secure cookie established by a simple login form,
|
||||
- do not put API keys in query strings,
|
||||
- validate anti-forgery tokens for login and logout posts.
|
||||
|
||||
Simplest implementation path:
|
||||
The implementation path is:
|
||||
|
||||
1. Add `/dashboard/login`.
|
||||
2. User submits API key over HTTPS.
|
||||
@@ -275,6 +278,13 @@ Simplest implementation path:
|
||||
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
||||
option. It must default to false.
|
||||
|
||||
`DashboardAuthenticator` keeps API-key validation outside UI components. It
|
||||
formats the submitted key as a bearer authorization header for
|
||||
`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is
|
||||
enabled, and creates the dashboard cookie principal without storing raw API key
|
||||
material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and
|
||||
explicit loopback bypass decisions for all protected dashboard routes.
|
||||
|
||||
## Configuration
|
||||
|
||||
Suggested configuration:
|
||||
@@ -308,7 +318,9 @@ Suggested configuration:
|
||||
|
||||
## Styling
|
||||
|
||||
Use Bootstrap utility classes and a small local stylesheet.
|
||||
The dashboard serves Bootstrap 5.3.3 assets from
|
||||
`src/MxGateway.Server/wwwroot/lib/bootstrap/` and local layout/status styling
|
||||
from `src/MxGateway.Server/wwwroot/css/dashboard.css`.
|
||||
|
||||
Recommended visual language:
|
||||
|
||||
@@ -349,16 +361,18 @@ Integration tests should verify:
|
||||
|
||||
## Initial Implementation Slice
|
||||
|
||||
The first dashboard slice should implement:
|
||||
The first dashboard slice implements:
|
||||
|
||||
1. Blazor Server hosting in `MxGateway.Server`.
|
||||
2. Bootstrap static assets.
|
||||
2. local Bootstrap static assets.
|
||||
3. dashboard configuration binding.
|
||||
4. dashboard auth using API key login and HTTP-only cookie.
|
||||
5. read-only `DashboardSnapshotService`.
|
||||
6. home page with metric cards.
|
||||
7. sessions page with active session table.
|
||||
7. sessions page with active session table and session details.
|
||||
8. workers page with worker table.
|
||||
9. 1-second realtime refresh through Blazor Server.
|
||||
10. redaction tests for secrets.
|
||||
|
||||
9. events page with aggregate counters.
|
||||
10. settings page with redacted effective configuration.
|
||||
11. periodic realtime refresh through Blazor Server.
|
||||
12. route-mapping tests, disabled-dashboard tests, auth tests, and snapshot
|
||||
projection/redaction tests.
|
||||
|
||||
+197
-14
@@ -64,8 +64,8 @@ MxGateway.Server
|
||||
Configuration
|
||||
Grpc
|
||||
MxAccessGatewayService
|
||||
RequestReplyMapper
|
||||
EventMapper
|
||||
MxAccessGrpcRequestValidator
|
||||
MxAccessGrpcMapper
|
||||
Dashboard
|
||||
Pages
|
||||
Components
|
||||
@@ -105,6 +105,15 @@ service MxAccessGateway {
|
||||
}
|
||||
```
|
||||
|
||||
`MxAccessGatewayService` implements these public RPCs in the gateway process.
|
||||
It validates public requests with `MxAccessGrpcRequestValidator`, delegates
|
||||
session lifecycle and command routing to `ISessionManager`, and maps worker
|
||||
command replies and events through `MxAccessGrpcMapper`. Session lookup,
|
||||
validation, and worker transport failures become gRPC status errors. MXAccess
|
||||
method replies that reached the worker remain `MxCommandReply` payloads so
|
||||
HRESULT values, status arrays, and method-specific reply fields survive
|
||||
transport boundaries.
|
||||
|
||||
Add this later only after the command and event model is stable:
|
||||
|
||||
```protobuf
|
||||
@@ -166,6 +175,12 @@ Behavior:
|
||||
`CloseSession` should be idempotent. Closing an already closed session should
|
||||
return a successful close result with the final known state.
|
||||
|
||||
`WorkerClient.ShutdownAsync` sends `WorkerShutdown`, waits for the worker read,
|
||||
write, and heartbeat loops to stop, and waits for the launched worker process to
|
||||
exit within the same shutdown timeout. If the pipe loops or process exit exceed
|
||||
the timeout, the close operation fails with `ShutdownTimeout`; `GatewaySession`
|
||||
then kills the worker process tree before surfacing the close failure.
|
||||
|
||||
### Invoke
|
||||
|
||||
`Invoke` forwards one MXAccess command to the worker that owns the session.
|
||||
@@ -197,13 +212,23 @@ accounting and a clear fan-out policy.
|
||||
Behavior:
|
||||
|
||||
1. Validate session id and authorize event access.
|
||||
2. Attach a stream cursor to the session event channel.
|
||||
3. Send events in worker sequence order.
|
||||
4. Stop on client cancellation, session close, or session fault.
|
||||
5. Emit a terminal status when the session faults if gRPC status alone cannot
|
||||
2. Attach the single active subscriber lease for the session.
|
||||
3. Read worker events into a bounded public stream queue.
|
||||
4. Send events in worker sequence order.
|
||||
5. Stop on client cancellation, session close, or session fault.
|
||||
6. Emit a terminal status when the session faults if gRPC status alone cannot
|
||||
preserve the required details.
|
||||
|
||||
The gateway must not reorder events from one worker.
|
||||
`EventStreamService` owns subscriber tracking and public stream backpressure.
|
||||
The default policy allows one active subscriber per session. A second subscriber
|
||||
is rejected with `EventSubscriberAlreadyActive`. Stream cancellation releases
|
||||
the subscriber lease so a later stream can attach to the session.
|
||||
|
||||
The gateway must not reorder events from one worker. `EventStreamService` writes
|
||||
mapped events to a bounded first-in, first-out queue and faults the session with
|
||||
`EventQueueOverflow` if the queue fills. The gateway does not synthesize
|
||||
`OperationComplete`; it forwards that family only when the worker reports a
|
||||
native MXAccess `OperationComplete` event.
|
||||
|
||||
## Web Dashboard
|
||||
|
||||
@@ -330,6 +355,20 @@ The worker remains authoritative for MXAccess handles. The gateway may keep a
|
||||
shadow state for diagnostics, but it must not invent, rewrite, or recycle
|
||||
MXAccess handles.
|
||||
|
||||
`SessionManager` owns the current in-memory session registry. It allocates a
|
||||
session id, creates the worker pipe name and nonce, registers the session before
|
||||
worker startup, and removes the session if startup fails. A successful
|
||||
`OpenSession` attaches the ready `IWorkerClient` and transitions the session to
|
||||
`Ready`.
|
||||
|
||||
Only `Ready` sessions accept command and event operations. `CloseSession` is
|
||||
idempotent for sessions still known to the registry: the first close shuts down
|
||||
the worker, and later closes return the final `Closed` state. Lease handling is
|
||||
exposed as a session hook so a monitor can close expired sessions without
|
||||
embedding lease policy in the worker client. Gateway shutdown walks the
|
||||
registry, closes each known session, and kills a worker if graceful shutdown
|
||||
fails.
|
||||
|
||||
## Worker Launch
|
||||
|
||||
The gateway should launch the worker using explicit configuration:
|
||||
@@ -360,6 +399,15 @@ Before launch, validate:
|
||||
- worker file version or product version is acceptable,
|
||||
- worker is expected to be x86.
|
||||
|
||||
`WorkerProcessLauncher` implements the first validation layer now: it resolves
|
||||
the worker executable path, requires a `.exe`, validates the Windows Portable
|
||||
Executable header, and verifies the configured processor architecture. It passes
|
||||
only `--session-id`, `--pipe-name`, and `--protocol-version` on the command
|
||||
line. The per-session nonce is set through `MXGATEWAY_WORKER_NONCE` so the
|
||||
command line remains safe to log. Startup failures and startup timeouts kill and
|
||||
dispose the worker process and the pre-created pipe reservation before the
|
||||
session manager observes the failure.
|
||||
|
||||
## Worker IPC
|
||||
|
||||
The gateway creates the pipe server before launching the worker.
|
||||
@@ -402,7 +450,7 @@ session ids as protocol faults and close the session.
|
||||
|
||||
`WorkerClient` is the gateway-side object that owns one worker connection.
|
||||
|
||||
Suggested public shape:
|
||||
Current public shape:
|
||||
|
||||
```csharp
|
||||
public interface IWorkerClient : IAsyncDisposable
|
||||
@@ -410,6 +458,7 @@ public interface IWorkerClient : IAsyncDisposable
|
||||
string SessionId { get; }
|
||||
int? ProcessId { get; }
|
||||
WorkerClientState State { get; }
|
||||
DateTimeOffset LastHeartbeatAt { get; }
|
||||
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
Task<WorkerCommandReply> InvokeAsync(
|
||||
@@ -429,12 +478,17 @@ Internally it owns:
|
||||
- pipe stream,
|
||||
- read loop,
|
||||
- write loop,
|
||||
- bounded outbound command/control channel,
|
||||
- outbound command/control channel serialized by the write loop,
|
||||
- bounded inbound event channel,
|
||||
- pending command dictionary keyed by correlation id,
|
||||
- heartbeat monitor,
|
||||
- terminal fault source.
|
||||
|
||||
`StartAsync` sends `GatewayHello`, verifies the `WorkerHello` protocol version
|
||||
and nonce, waits for `WorkerReady`, and only then exposes `Ready` state. The
|
||||
read loop starts after readiness so the handshake has a single owner for its
|
||||
ordered frames.
|
||||
|
||||
### Read Loop
|
||||
|
||||
The read loop:
|
||||
@@ -467,6 +521,11 @@ It handles:
|
||||
The write loop should fail the session if a pipe write fails outside normal
|
||||
shutdown.
|
||||
|
||||
During shutdown the worker client treats `WorkerShutdownAck` as the protocol
|
||||
close signal, but the process handle remains authoritative for process lifetime.
|
||||
The client waits for both the protocol close and process exit before reporting a
|
||||
clean shutdown to `GatewaySession`.
|
||||
|
||||
## Command Correlation
|
||||
|
||||
Each command gets:
|
||||
@@ -546,7 +605,8 @@ worker MXAccess event
|
||||
-> worker outbound event queue
|
||||
-> worker pipe writer
|
||||
-> gateway read loop
|
||||
-> session event channel
|
||||
-> worker client event queue
|
||||
-> EventStreamService bounded stream queue
|
||||
-> gRPC StreamEvents
|
||||
```
|
||||
|
||||
@@ -560,13 +620,15 @@ The gateway should record:
|
||||
|
||||
Default backpressure policy for parity testing should be fail-fast:
|
||||
|
||||
1. If the session event channel fills, fault the session.
|
||||
1. If the worker client event queue fills, fault the worker client.
|
||||
2. If the public stream queue fills, fault the gateway session.
|
||||
2. Preserve the overflow details in logs and metrics.
|
||||
3. Do not silently drop data-change events.
|
||||
|
||||
Do not set a production event-rate target before measurement. Emit event rate,
|
||||
queue depth, stream send latency, and overflow metrics. Later production modes
|
||||
may support explicit coalescing by item handle as an opt-in behavior.
|
||||
Do not set a production event-rate target before measurement. `GatewayMetrics`
|
||||
records received event counts by family, queue depth, stream disconnects, and
|
||||
overflow counts. Later production modes may support explicit coalescing by item
|
||||
handle as an opt-in behavior.
|
||||
|
||||
The gateway should not synthesize `OperationComplete` from write completion,
|
||||
command replies, ASB completion queues, or completion-only status frames. Forward
|
||||
@@ -589,6 +651,39 @@ The gateway should split the key into a stable key id and secret component,
|
||||
load the key record by id, hash the presented secret, and compare using a
|
||||
constant-time comparison.
|
||||
|
||||
`ApiKeyParser` accepts only `authorization: Bearer mxgw_<key-id>_<secret>`.
|
||||
Malformed headers fail before any database lookup. The parsed raw secret is
|
||||
kept only long enough for `ApiKeySecretHasher` to compute an HMAC-SHA256 hash
|
||||
using the configured `Authentication:PepperSecretName` lookup in application
|
||||
configuration. The raw secret is not stored in the auth database, identity
|
||||
model, logs, or verification result.
|
||||
|
||||
`ApiKeyVerifier` loads the stored key record by key id, rejects revoked keys,
|
||||
hashes the presented secret, and compares the stored and presented hashes with
|
||||
`CryptographicOperations.FixedTimeEquals`. A successful verification returns an
|
||||
`ApiKeyIdentity` with key id, key prefix, display name, and scopes. Failure
|
||||
results distinguish malformed credentials, missing keys, revoked keys, missing
|
||||
pepper configuration, and hash mismatch for internal authorization handling.
|
||||
|
||||
`GatewayGrpcAuthorizationInterceptor` enforces this authentication model for
|
||||
public gRPC calls. Missing, malformed, revoked, unknown, or mismatched keys fail
|
||||
with `Unauthenticated`. Authenticated calls missing the scope required by the
|
||||
RPC fail with `PermissionDenied`. The interceptor applies to unary calls and
|
||||
server-streaming calls and stores the authenticated `ApiKeyIdentity` in
|
||||
`IGatewayRequestIdentityAccessor` for the duration of the request handler.
|
||||
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
|
||||
development only.
|
||||
|
||||
Dashboard authentication reuses the API-key verifier and scope model. The
|
||||
dashboard login endpoint accepts the key in a form post, checks `admin` scope
|
||||
when `Dashboard:RequireAdminScope` is enabled, and signs in with the
|
||||
`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict
|
||||
SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears
|
||||
that cookie. Login and logout posts use anti-forgery validation, and dashboard
|
||||
API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost`
|
||||
allows only loopback requests to bypass the dashboard cookie requirement and
|
||||
defaults to `false`.
|
||||
|
||||
Recommended scopes:
|
||||
|
||||
- `session:open`
|
||||
@@ -608,10 +703,44 @@ gRPC admin API. It should initialize the auth database, create keys, list keys
|
||||
without secrets, revoke keys, rotate keys, and print raw secrets only once at
|
||||
creation.
|
||||
|
||||
`MxGateway.Server` exposes local API-key administration as an `apikey`
|
||||
subcommand before the web host starts:
|
||||
|
||||
```bash
|
||||
MxGateway.Server apikey init-db --sqlite-path C:\ProgramData\MxGateway\gateway-auth.db
|
||||
MxGateway.Server apikey create-key --key-id operator01 --display-name Operator --scopes session:open,events:read
|
||||
MxGateway.Server apikey list-keys --json
|
||||
MxGateway.Server apikey revoke-key --key-id operator01
|
||||
MxGateway.Server apikey rotate-key --key-id operator01 --json
|
||||
```
|
||||
|
||||
The subcommands accept `--sqlite-path`, `--pepper`, and `--json`. `--pepper`
|
||||
sets the local `MxGateway:ApiKeyPepper` configuration value for the command
|
||||
process; deployments should normally provide the pepper through the configured
|
||||
secret source. `create-key` and `rotate-key` print the full raw API key exactly
|
||||
once. `list-keys` never prints raw secrets or `secret_hash` values.
|
||||
|
||||
SQLite auth storage should use startup migrations with a `schema_version` table.
|
||||
Migrations should run inside transactions and fail startup if the database
|
||||
schema is newer than the running binary understands.
|
||||
|
||||
The v1 auth store uses `Microsoft.Data.Sqlite` and creates the
|
||||
`schema_version`, `api_keys`, and `api_key_audit` tables through
|
||||
`SqliteAuthStoreMigrator`. `AuthStoreMigrationHostedService` runs those
|
||||
migrations at gateway startup when API-key authentication and
|
||||
`Authentication:RunMigrationsOnStartup` are enabled. A database with a newer
|
||||
schema version fails startup instead of being modified by an older gateway
|
||||
binary.
|
||||
|
||||
`IApiKeyStore` reads stored key records and exposes an active-key lookup that
|
||||
excludes rows with `revoked_utc` set. Hash verification belongs to the API-key
|
||||
hashing layer, but the store preserves the `secret_hash` bytes, display name,
|
||||
scopes, timestamps, and revocation state needed by that layer.
|
||||
|
||||
`IApiKeyAuditStore` appends audit events to `api_key_audit` and returns recent
|
||||
events for diagnostics and future administrative tools. Audit records store key
|
||||
ids and event metadata only; they do not store raw API key secrets.
|
||||
|
||||
Commands requiring authorization:
|
||||
|
||||
- writes,
|
||||
@@ -620,6 +749,20 @@ Commands requiring authorization:
|
||||
- worker shutdown diagnostics,
|
||||
- metadata queries if they expose sensitive plant structure.
|
||||
|
||||
Current gRPC scope mapping:
|
||||
|
||||
- `OpenSession` requires `session:open`.
|
||||
- `CloseSession` requires `session:close`.
|
||||
- `StreamEvents` and `DrainEvents` require `events:read`.
|
||||
- read-style MXAccess commands such as `Register`, `AddItem`, `Advise`, and
|
||||
`Ping` require `invoke:read`.
|
||||
- `Write` and `Write2` require `invoke:write`.
|
||||
- `WriteSecured`, `WriteSecured2`, and `AuthenticateUser` require
|
||||
`invoke:secure`.
|
||||
- metadata commands such as `ArchestrAUserToId`, `GetSessionState`, and
|
||||
`GetWorkerInfo` require `metadata:read`.
|
||||
- `ShutdownWorker` requires `admin`.
|
||||
|
||||
### Worker IPC
|
||||
|
||||
Named pipes should be local only. Pipe ACLs should restrict access to:
|
||||
@@ -664,6 +807,26 @@ Metrics:
|
||||
|
||||
Do not log credential values or full tag values by default.
|
||||
|
||||
The gateway registers `GatewayMetrics` as the in-process metrics foundation.
|
||||
It emits .NET `Meter` instruments for collectors and keeps a
|
||||
`GatewayMetricsSnapshot` for dashboard projection. The snapshot exists because
|
||||
the dashboard needs current counters and queue depths without depending on a
|
||||
specific metrics exporter.
|
||||
|
||||
HTTP request handling uses `UseGatewayRequestLoggingScope()` to attach common
|
||||
structured log fields when request metadata is present:
|
||||
|
||||
- `SessionId`,
|
||||
- `ClientIdentity`,
|
||||
- `WorkerProcessId`,
|
||||
- `CorrelationId`,
|
||||
- `CommandMethod`.
|
||||
|
||||
`GatewayLogRedactor` redacts API key secrets and command values before they are
|
||||
added to log state. Value logging remains opt-in and redacted by default so
|
||||
secured writes, authentication commands, and ordinary tag values do not leak
|
||||
through diagnostics.
|
||||
|
||||
## Configuration
|
||||
|
||||
Suggested configuration shape:
|
||||
@@ -710,6 +873,18 @@ Suggested configuration shape:
|
||||
|
||||
Do not scatter connection or path constants through implementation code.
|
||||
|
||||
`MxGateway.Server` binds this section to `GatewayOptions` at startup and
|
||||
registers validation with `ValidateOnStart()`. Startup fails before the gateway
|
||||
begins serving traffic when required authentication settings are missing,
|
||||
timeouts or queue sizes are not positive, dashboard settings are malformed, or
|
||||
the configured worker protocol version does not match the contract version.
|
||||
|
||||
The gateway exposes read-only effective settings through
|
||||
`IGatewayConfigurationProvider`. This projection is for dashboard settings and
|
||||
diagnostics, so it redacts secret-related fields such as
|
||||
`Authentication:PepperSecretName` and does not include raw API keys or key
|
||||
material.
|
||||
|
||||
## Galaxy Repository Metadata
|
||||
|
||||
Galaxy hierarchy and tag metadata can be discovered through SQL Server when
|
||||
@@ -727,9 +902,17 @@ behavior unless an explicit non-parity backend is designed.
|
||||
Gateway tests should be able to run without installed MXAccess by using fake
|
||||
workers and fake transports.
|
||||
|
||||
Use `FakeWorkerHarness` for tests that need real gateway-to-worker framing,
|
||||
handshake, command, event, fault, or malformed-protocol behavior without loading
|
||||
MXAccess COM. See [Gateway Testing](./GatewayTesting.md) for the harness scope
|
||||
and focused test commands.
|
||||
|
||||
Focused tests:
|
||||
|
||||
- session state transitions,
|
||||
- gRPC API-key authentication for unary and streaming calls,
|
||||
- gRPC scope mapping for sessions, invokes, events, metadata, and admin
|
||||
commands,
|
||||
- worker startup failures,
|
||||
- protocol version mismatch,
|
||||
- malformed frame handling,
|
||||
|
||||
@@ -189,6 +189,8 @@ Tests:
|
||||
|
||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||
|
||||
Status: implemented.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `Register`,
|
||||
@@ -216,6 +218,8 @@ Live tests:
|
||||
|
||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||
|
||||
Status: implemented.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- `AddItem`,
|
||||
@@ -273,6 +277,8 @@ Live tests:
|
||||
|
||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||
|
||||
Status: implemented.
|
||||
|
||||
Deliverables:
|
||||
|
||||
- handlers for `OnDataChange`,
|
||||
@@ -447,4 +453,3 @@ Acceptance criteria:
|
||||
|
||||
- each public method has planned parity fixture or documented gap,
|
||||
- gateway results preserve HRESULT/status/value/event shape.
|
||||
|
||||
|
||||
@@ -26,6 +26,33 @@ Style guides:
|
||||
- [C# Style Guide](./style-guides/CSharpStyleGuide.md)
|
||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||
|
||||
## Build And Test
|
||||
|
||||
Build the SDK-style worker project with the .NET SDK MSBuild entry point. The
|
||||
project targets .NET Framework 4.8, but the SDK resolver comes from the .NET SDK
|
||||
installation:
|
||||
|
||||
```powershell
|
||||
dotnet msbuild src\MxGateway.Worker\MxGateway.Worker.csproj /restore /p:Configuration=Debug /p:Platform=x86
|
||||
```
|
||||
|
||||
`docs/toolchain-links.md` records the Visual Studio MSBuild executable for
|
||||
classic .NET Framework and COM interop builds:
|
||||
|
||||
```powershell
|
||||
& "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" src\MxGateway.Worker\MxGateway.Worker.csproj /p:Configuration=Debug /p:Platform=x86
|
||||
```
|
||||
|
||||
Run the worker tests with the same platform target:
|
||||
|
||||
```powershell
|
||||
dotnet test src\MxGateway.Worker.Tests\MxGateway.Worker.Tests.csproj -p:Platform=x86
|
||||
```
|
||||
|
||||
The only MXAccess interop reference belongs in `MxGateway.Worker`. Gateway and
|
||||
test projects may reference the worker project for metadata and scaffold tests,
|
||||
but they must not reference `ArchestrA.MXAccess.dll` directly.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
The worker owns:
|
||||
@@ -87,6 +114,21 @@ Startup sequence:
|
||||
If validation fails before MXAccess creation, exit quickly with a non-zero exit
|
||||
code. If MXAccess creation fails, send `WorkerFault` when possible and exit.
|
||||
|
||||
The bootstrap layer returns structured exit codes before it creates pipes,
|
||||
starts the STA, or touches MXAccess:
|
||||
|
||||
| Exit code | Name | Meaning |
|
||||
|-----------|------|---------|
|
||||
| `0` | `Success` | Required bootstrap options are valid. |
|
||||
| `1` | `UnexpectedFailure` | A non-bootstrap exception reaches the process boundary. |
|
||||
| `2` | `InvalidArguments` | Required arguments are missing or unknown arguments are present. |
|
||||
| `3` | `InvalidProtocolVersion` | `--protocol-version` is not numeric or does not match the supported worker protocol. |
|
||||
| `4` | `MissingNonce` | `MXGATEWAY_WORKER_NONCE` is absent or empty. |
|
||||
|
||||
Bootstrap logs use `WorkerConsoleLogger` key/value output. `WorkerLogRedactor`
|
||||
redacts fields whose names indicate nonce, secret, password, token,
|
||||
credential, or API key values before the message is written.
|
||||
|
||||
## Internal Components
|
||||
|
||||
```text
|
||||
@@ -208,6 +250,17 @@ The loop should update a heartbeat timestamp after:
|
||||
- finishing a command,
|
||||
- processing an MXAccess event.
|
||||
|
||||
`StaRuntime` implements this runtime boundary in the worker. It starts one
|
||||
background thread named `MxGateway.Worker.STA`, sets it to `ApartmentState.STA`,
|
||||
initializes COM through `StaComApartmentInitializer`, and runs
|
||||
`StaMessagePump`. Commands are scheduled through `InvokeAsync`; the command
|
||||
queue signals an `AutoResetEvent` so `MsgWaitForMultipleObjectsEx` can wake the
|
||||
STA without busy-waiting. `LastActivityUtc` records pump, command, startup, and
|
||||
shutdown activity so the future heartbeat/watchdog can report whether the STA
|
||||
is still responsive. Shutdown marks the runtime as closing, wakes the pump,
|
||||
rejects new commands, cancels queued work, uninitializes COM on the STA, and
|
||||
waits for the thread to exit.
|
||||
|
||||
## COM Creation
|
||||
|
||||
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
|
||||
@@ -236,6 +289,16 @@ The worker should reference the interop assembly and instantiate
|
||||
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
|
||||
path configurable for diagnostics, but this COM class is the v1 default.
|
||||
|
||||
`MxAccessStaSession` owns the initial COM creation path. It starts `StaRuntime`,
|
||||
creates `LMXProxyServerClass` through `MxAccessComObjectFactory` on the STA,
|
||||
attaches `MxAccessBaseEventSink`, and returns `WorkerReady` only after those
|
||||
steps succeed. `MxAccessSession` keeps the raw COM object private, records the
|
||||
STA managed thread id that created it, detaches the base event sink during
|
||||
disposal, and releases the COM reference on the STA. After creation,
|
||||
`MxAccessStaSession` owns a `StaCommandDispatcher` backed by
|
||||
`MxAccessCommandExecutor`; `DispatchAsync` queues contract commands back to the
|
||||
same STA instead of exposing the COM object to callers.
|
||||
|
||||
Creation rules:
|
||||
|
||||
- Create COM object only on the STA.
|
||||
@@ -253,6 +316,18 @@ If COM creation fails, the worker should send a structured fault with:
|
||||
- worker process id,
|
||||
- session id.
|
||||
|
||||
`WorkerPipeSession` maps startup exceptions from this path to
|
||||
`WorkerFaultCategory.MxaccessCreationFailed`, includes the captured HRESULT
|
||||
when the exception exposes one, and does not send `WorkerReady` after a failed
|
||||
COM creation attempt.
|
||||
|
||||
After `WorkerReady`, `WorkerPipeSession` continues reading gateway frames for
|
||||
the lifetime of the process. `WorkerCommand` frames are dispatched to
|
||||
`MxAccessStaSession`, replies are written as `WorkerCommandReply`, and queued
|
||||
worker events are drained after command replies. `WorkerShutdown` starts the
|
||||
graceful shutdown path and returns `WorkerShutdownAck` only after the STA
|
||||
cleanup path completes.
|
||||
|
||||
## Event Sink
|
||||
|
||||
The worker must subscribe to every public MXAccess event family:
|
||||
@@ -280,9 +355,28 @@ Event handling rules:
|
||||
- Enqueue to the outbound event queue.
|
||||
- Return quickly to preserve message pumping.
|
||||
|
||||
If event conversion throws, catch it inside the event handler, enqueue a
|
||||
structured `WorkerFault` or diagnostic event, and keep the worker alive only if
|
||||
the fault policy allows it.
|
||||
`MxAccessBaseEventSink` implements the COM connection-point handlers and keeps
|
||||
the handlers limited to event argument conversion plus enqueue. It uses
|
||||
`MxAccessEventMapper` to create `MxEvent` DTOs for `OnDataChange`,
|
||||
`OnWriteComplete`, `OperationComplete`, and `OnBufferedDataChange`. The mapper
|
||||
converts scalar and array values through `VariantConverter`, converts
|
||||
`MXSTATUS_PROXY[]` through `MxStatusProxyConverter`, and maps installed
|
||||
`MxDataType` values to the public protobuf enum while preserving the raw data
|
||||
type on buffered events. `OperationComplete` is only emitted from the native
|
||||
`OperationComplete` handler; write completion does not synthesize it.
|
||||
|
||||
`MxAccessEventQueue` is the bounded outbound event queue for one worker
|
||||
session. It assigns the monotonic `WorkerSequence` and `WorkerTimestamp` when an
|
||||
event is accepted, preserving the order in which MXAccess handlers enqueue
|
||||
events. The default capacity is `10000`. When the queue reaches capacity it
|
||||
records a `WorkerFaultCategory.QueueOverflow` fault and rejects further events.
|
||||
The event handler catches conversion and enqueue failures, records the first
|
||||
fault on the queue, and returns to the STA message pump instead of writing to
|
||||
the pipe.
|
||||
|
||||
If event conversion throws, catch it inside the event handler, record a
|
||||
structured `WorkerFault`, and keep the worker alive only if the fault policy
|
||||
allows it.
|
||||
|
||||
## Command Queue
|
||||
|
||||
@@ -349,6 +443,60 @@ Diagnostics:
|
||||
Implement method-specific dispatch instead of a generic string method invoker.
|
||||
Parity tests need stable command-specific request and reply shapes.
|
||||
|
||||
`MxAccessCommandExecutor` implements the first command pair:
|
||||
|
||||
- `Register` calls `LMXProxyServerClass.Register` with the requested client
|
||||
name and preserves the returned server handle in both `ReturnValue` and
|
||||
`RegisterReply.ServerHandle`.
|
||||
- `Unregister` calls `LMXProxyServerClass.Unregister` with the requested server
|
||||
handle. The reply has no method-specific payload because the public MXAccess
|
||||
method returns `void`.
|
||||
|
||||
Both commands set `Hresult` to `0` only after the COM call returns normally.
|
||||
COM exceptions flow through `StaCommandDispatcher`, which captures the thrown
|
||||
HRESULT and converts the reply to `ProtocolStatusCode.MxaccessFailure`.
|
||||
`MxAccessStaSession.GetRegisteredServerHandlesAsync` returns an STA-read
|
||||
snapshot of tracked server handles for diagnostics and future cleanup logic.
|
||||
|
||||
`MxAccessCommandExecutor` also implements the item lifecycle commands:
|
||||
|
||||
- `AddItem` calls `LMXProxyServerClass.AddItem` with the requested server
|
||||
handle and item definition. It preserves the returned item handle in both
|
||||
`ReturnValue` and `AddItemReply.ItemHandle`.
|
||||
- `AddItem2` calls `LMXProxyServerClass.AddItem2` with the requested server
|
||||
handle, item definition, and context string. The context string is passed to
|
||||
MXAccess exactly as received.
|
||||
- `RemoveItem` calls `LMXProxyServerClass.RemoveItem` with the requested server
|
||||
handle and item handle. The reply has no method-specific payload because the
|
||||
public MXAccess method returns `void`.
|
||||
|
||||
The worker records item handles only after `AddItem` or `AddItem2` returns
|
||||
normally, and removes item handles only after `RemoveItem` returns normally.
|
||||
The registry does not prevalidate server or item handles, so invalid and
|
||||
cross-server handle behavior remains owned by MXAccess. COM exceptions continue
|
||||
through `StaCommandDispatcher`, which preserves the HRESULT and leaves
|
||||
diagnostic registry state unchanged for failed cleanup calls.
|
||||
|
||||
`MxAccessCommandExecutor` implements advice lifecycle commands on the same STA
|
||||
path:
|
||||
|
||||
- `Advise` calls `LMXProxyServerClass.Advise` with the requested server handle
|
||||
and item handle.
|
||||
- `AdviseSupervisory` calls `LMXProxyServerClass.AdviseSupervisory` with the
|
||||
requested server handle and item handle. This remains a distinct command from
|
||||
plain `Advise` even though observed scalar captures share the same lower-level
|
||||
subscription body.
|
||||
- `UnAdvise` calls `LMXProxyServerClass.UnAdvise` with the requested server
|
||||
handle and item handle.
|
||||
|
||||
The worker records plain and supervisory advice separately only after the COM
|
||||
call returns normally. Successful `UnAdvise` removes all tracked advice for the
|
||||
server and item pair because the public MXAccess cleanup method has no plain
|
||||
versus supervisory selector. Successful `RemoveItem` and `Unregister` also clear
|
||||
related advice state from the worker registry. Failed advice and cleanup calls
|
||||
leave registry state unchanged so diagnostics continue to reflect the last
|
||||
successful MXAccess-owned state transition.
|
||||
|
||||
## Handle Registry
|
||||
|
||||
The worker should track MXAccess state for diagnostics and cleanup, while still
|
||||
@@ -369,6 +517,13 @@ Rules:
|
||||
|
||||
- Do not invent handles.
|
||||
- Do not rewrite handles returned by MXAccess.
|
||||
- Record server handles only after `Register` succeeds.
|
||||
- Remove server handles only after `Unregister` succeeds.
|
||||
- Record item handles only after `AddItem` or `AddItem2` succeeds.
|
||||
- Remove item handles only after `RemoveItem` succeeds.
|
||||
- Record advice state only after `Advise` or `AdviseSupervisory` succeeds.
|
||||
- Remove advice state only after `UnAdvise`, `RemoveItem`, or `Unregister`
|
||||
succeeds.
|
||||
- Preserve invalid-handle behavior from MXAccess.
|
||||
- Preserve cross-server handle behavior from MXAccess.
|
||||
- Use registry state for cleanup and diagnostics, not semantic correction.
|
||||
@@ -470,13 +625,19 @@ Do not drop or coalesce events in v1.
|
||||
|
||||
## Heartbeat And Watchdog
|
||||
|
||||
The worker heartbeat should prove that:
|
||||
`WorkerPipeSession` starts the heartbeat loop after the gateway validates
|
||||
`WorkerHello` and receives `WorkerReady`. Heartbeats continue until
|
||||
`WorkerShutdown`, cancellation, or a pipe/protocol failure stops the session.
|
||||
The loop uses `WorkerPipeSessionOptions.HeartbeatInterval`; the default matches
|
||||
the gateway worker heartbeat interval.
|
||||
|
||||
The worker heartbeat proves that:
|
||||
|
||||
- pipe writer is alive,
|
||||
- worker host is alive,
|
||||
- STA has recently pumped or completed work.
|
||||
|
||||
Heartbeat payload should include:
|
||||
Heartbeat payload includes:
|
||||
|
||||
- worker process id,
|
||||
- session id,
|
||||
@@ -487,13 +648,19 @@ Heartbeat payload should include:
|
||||
- event sequence,
|
||||
- current command correlation id if any.
|
||||
|
||||
The STA watchdog should warn when:
|
||||
`MxAccessStaSession.CaptureHeartbeat()` reads `StaRuntime.LastActivityUtc` and
|
||||
`StaCommandDispatcher` queue state without touching the raw MXAccess COM object
|
||||
outside the STA. Event queue depth and event sequence are reported as zero until
|
||||
the event queue implementation owns those counters.
|
||||
|
||||
- one command exceeds its expected duration,
|
||||
- the STA has not pumped messages within the heartbeat grace period,
|
||||
- event queue depth remains high.
|
||||
|
||||
The worker can report the problem, but the gateway owns the final kill decision.
|
||||
The STA watchdog currently emits a `WorkerFault` with
|
||||
`WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than
|
||||
`WorkerPipeSessionOptions.HeartbeatGrace`. The fault includes the current
|
||||
command correlation id when a command is active. Command duration and high event
|
||||
queue depth remain observable through heartbeat fields until dedicated
|
||||
thresholds own those warnings. The worker reports stale STA activity, but the
|
||||
gateway owns the final kill decision through its existing heartbeat and worker
|
||||
lifecycle policy.
|
||||
|
||||
## Shutdown
|
||||
|
||||
@@ -515,6 +682,29 @@ Graceful shutdown sequence:
|
||||
If shutdown wedges, the gateway kills the process. The worker should be written
|
||||
so process kill does not corrupt other sessions.
|
||||
|
||||
`MxAccessStaSession.ShutdownGracefullyAsync` implements the current cleanup
|
||||
path. It first calls `StaCommandDispatcher.RequestShutdown()` so new commands
|
||||
are rejected and queued commands that have not started receive
|
||||
`ProtocolStatusCode.WorkerUnavailable`. The command already executing on the
|
||||
STA is allowed to finish until the shutdown grace period expires.
|
||||
|
||||
After command dispatch is closed, cleanup runs on the STA in MXAccess handle
|
||||
order:
|
||||
|
||||
1. one `UnAdvise` call per advised server/item pair,
|
||||
2. `RemoveItem` for active item handles,
|
||||
3. `Unregister` for active server handles,
|
||||
4. event sink detach,
|
||||
5. COM release.
|
||||
|
||||
Each cleanup call is best effort. A failed cleanup operation is recorded as an
|
||||
`MxAccessShutdownFailure`, logged by `WorkerPipeSession`, and does not prevent
|
||||
later cleanup calls from running. A shutdown with cleanup failures still returns
|
||||
`WorkerShutdownAck` with `ProtocolStatusCode.Ok` because the worker reached the
|
||||
controlled release path. If the grace period expires before cleanup can run or
|
||||
finish, the worker reports `WorkerFaultCategory.ShutdownTimeout` when possible
|
||||
and relies on the gateway to kill the process.
|
||||
|
||||
## Fault Handling
|
||||
|
||||
Worker fault categories:
|
||||
@@ -612,6 +802,10 @@ Live MXAccess tests:
|
||||
|
||||
Live tests should be opt-in and clearly marked because they depend on installed
|
||||
MXAccess COM and provider state.
|
||||
The worker test suite uses `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1` for these
|
||||
tests. `AddItem` uses `TestChildObject.TestInt` by default and accepts an
|
||||
override through `MXGATEWAY_LIVE_MXACCESS_ITEM`; `AddItem2` uses the captured
|
||||
parity fixture shape `AddItem2("TestInt", "TestChildObject")`.
|
||||
|
||||
## Initial Implementation Slice
|
||||
|
||||
|
||||
+61
-11
@@ -45,6 +45,10 @@ Detailed follow-up docs:
|
||||
- `docs/gateway-process-design.md` covers the .NET 10 gateway process,
|
||||
session manager, worker supervision, gRPC API, event streaming, fault model,
|
||||
security, observability, and test strategy.
|
||||
- `docs/WorkerFrameProtocol.md` covers the gateway-side named-pipe frame
|
||||
reader/writer and `WorkerEnvelope` validation rules.
|
||||
- `docs/WorkerProcessLauncher.md` covers worker executable validation, process
|
||||
launch arguments, nonce handling, and startup cleanup behavior.
|
||||
- `docs/mxaccess-worker-instance-design.md` covers each .NET Framework 4.8 x86
|
||||
MXAccess worker instance, including STA ownership, message pumping, COM
|
||||
lifetime, command dispatch, event sinks, conversion, and shutdown.
|
||||
@@ -97,6 +101,31 @@ Responsibilities:
|
||||
|
||||
The gateway must never instantiate or call MXAccess directly.
|
||||
|
||||
The gateway observability foundation lives in `MxGateway.Server.Diagnostics`
|
||||
and `MxGateway.Server.Metrics`. Structured logging scopes carry session,
|
||||
worker, correlation, command, and client identity fields with redaction applied
|
||||
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
|
||||
histograms through .NET `Meter` and a snapshot API that dashboard services can
|
||||
project without binding to a metrics exporter.
|
||||
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
|
||||
effective configuration into immutable DTOs for read-only dashboard rendering.
|
||||
The Blazor Server dashboard renders those snapshots at `/dashboard`,
|
||||
`/dashboard/sessions`, `/dashboard/workers`, `/dashboard/events`, and
|
||||
`/dashboard/settings`. Components subscribe to
|
||||
`IDashboardSnapshotService.WatchSnapshotsAsync()` and update on the configured
|
||||
snapshot interval without mutating session or worker state. The dashboard uses
|
||||
local Bootstrap CSS and JavaScript plus a small local stylesheet; it does not
|
||||
use a Blazor UI component library.
|
||||
|
||||
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
|
||||
accepts the API key in a form body, validates the configured `admin` scope,
|
||||
and issues an HTTP-only secure cookie for subsequent dashboard requests.
|
||||
`/dashboard/logout` clears that cookie. Login and logout posts validate
|
||||
anti-forgery tokens, and API keys are never accepted through query strings.
|
||||
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
|
||||
loopback requests only when explicitly enabled. Setting
|
||||
`MxGateway:Dashboard:Enabled` to `false` leaves the dashboard routes unmapped.
|
||||
|
||||
### Worker Process
|
||||
|
||||
Runtime:
|
||||
@@ -507,11 +536,7 @@ Worker policy:
|
||||
|
||||
- bounded outbound event channel,
|
||||
- never block MXAccess event handler on pipe writes,
|
||||
- if the outbound channel is full, apply configured policy:
|
||||
- disconnect session,
|
||||
- drop oldest low-priority data-change events,
|
||||
- coalesce data changes by item handle,
|
||||
- or block briefly then fault.
|
||||
- fail the worker session when the outbound channel is full.
|
||||
|
||||
For full parity testing, default should be fail-fast rather than silent drop.
|
||||
For production high-rate telemetry, add explicit coalescing modes.
|
||||
@@ -520,9 +545,15 @@ Gateway policy:
|
||||
|
||||
- one event sequencer per session,
|
||||
- preserve per-session event order,
|
||||
- support multiple client event subscribers only if explicitly required,
|
||||
- apply backpressure from slow gRPC streams,
|
||||
- disconnect or coalesce according to client-selected mode.
|
||||
- allow one active client event subscriber per session,
|
||||
- reject a second subscriber with a clear session error,
|
||||
- use a bounded `EventStreamService` queue between worker events and gRPC
|
||||
writes,
|
||||
- fault the session when the bounded stream queue overflows,
|
||||
- detach the subscriber when the stream is canceled.
|
||||
|
||||
The gateway forwards only events reported by the worker. It does not synthesize
|
||||
`OperationComplete` from write completion, command replies, or status frames.
|
||||
|
||||
## Isolation And Fault Handling
|
||||
|
||||
@@ -555,9 +586,13 @@ Because each client owns one worker, a crash or leak affects only that session.
|
||||
External gateway:
|
||||
|
||||
- use TLS for remote gRPC if crossing machine boundaries,
|
||||
- authenticate clients with Windows auth, mTLS, or a deployment-specific token,
|
||||
- authorize access to commands that can write, authenticate users, or alter
|
||||
runtime state.
|
||||
- authenticate v1 gRPC clients with `authorization: Bearer
|
||||
mxgw_<key-id>_<secret>` API-key metadata,
|
||||
- reject missing or invalid API keys with gRPC `Unauthenticated`,
|
||||
- reject valid keys that lack the required session, invoke, event, metadata, or
|
||||
admin scope with gRPC `PermissionDenied`,
|
||||
- authorize access to commands that can write, authenticate users, expose
|
||||
metadata, stream events, or alter runtime state.
|
||||
|
||||
Internal worker IPC:
|
||||
|
||||
@@ -784,6 +819,12 @@ Core operations:
|
||||
- track worker state,
|
||||
- close or kill worker.
|
||||
|
||||
The gateway implementation keeps sessions in an in-memory `SessionRegistry`
|
||||
keyed by session id. `SessionManager` owns the state machine, creates
|
||||
per-session pipe names and nonces, starts the worker through the worker-client
|
||||
factory, gates commands to `Ready` sessions, exposes lease-close hooks, and
|
||||
cleans up workers during gateway shutdown.
|
||||
|
||||
State machine:
|
||||
|
||||
```text
|
||||
@@ -831,6 +872,15 @@ The gRPC layer should be thin:
|
||||
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
|
||||
translation code testable.
|
||||
|
||||
The gateway maps `MxAccessGateway` to `MxAccessGatewayService`. The service
|
||||
implements `OpenSession`, `CloseSession`, `Invoke`, and `StreamEvents` by
|
||||
validating public requests, delegating session work to `ISessionManager`, and
|
||||
using explicit mapper code for public-to-worker commands and worker replies.
|
||||
`StreamEvents` delegates subscriber ownership, ordering, and backpressure to
|
||||
`EventStreamService`. Missing sessions and transport failures return gRPC
|
||||
status errors; worker command replies preserve MXAccess HRESULT and status
|
||||
details in the public reply.
|
||||
|
||||
## C# Worker Versus C++ Worker
|
||||
|
||||
Start with a C# .NET Framework 4.8 x86 worker.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Check
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$protoRoot = Join-Path $repoRoot "src/MxGateway.Contracts/Protos"
|
||||
$manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json"
|
||||
$descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset"
|
||||
|
||||
function Resolve-Protoc {
|
||||
$pathCommand = Get-Command "protoc.exe" -ErrorAction SilentlyContinue
|
||||
if ($null -ne $pathCommand) {
|
||||
return $pathCommand.Source
|
||||
}
|
||||
|
||||
$documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe"
|
||||
if (Test-Path $documentedPath) {
|
||||
return $documentedPath
|
||||
}
|
||||
|
||||
throw "Could not find protoc.exe. See docs/toolchain-links.md for the documented protobuf toolchain path."
|
||||
}
|
||||
|
||||
function Ensure-Directory {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
New-Item -ItemType Directory -Path $Path | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
function Compare-FileBytes {
|
||||
param(
|
||||
[string]$ExpectedPath,
|
||||
[string]$ActualPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $ExpectedPath)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$expected = [System.IO.File]::ReadAllBytes($ExpectedPath)
|
||||
$actual = [System.IO.File]::ReadAllBytes($ActualPath)
|
||||
if ($expected.Length -ne $actual.Length) {
|
||||
return $false
|
||||
}
|
||||
|
||||
for ($index = 0; $index -lt $expected.Length; $index++) {
|
||||
if ($expected[$index] -ne $actual[$index]) {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
return $true
|
||||
}
|
||||
|
||||
$manifest = Get-Content -Raw $manifestPath | ConvertFrom-Json
|
||||
|
||||
Ensure-Directory (Split-Path $descriptorPath -Parent)
|
||||
foreach ($output in $manifest.generatedOutputs.PSObject.Properties.Value) {
|
||||
Ensure-Directory (Join-Path $repoRoot $output)
|
||||
}
|
||||
|
||||
$protoc = Resolve-Protoc
|
||||
$outputPath = $descriptorPath
|
||||
if ($Check) {
|
||||
$outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-" + [System.Guid]::NewGuid().ToString("N") + ".protoset")
|
||||
}
|
||||
|
||||
try {
|
||||
& $protoc `
|
||||
"--proto_path=$protoRoot" `
|
||||
"--include_imports" `
|
||||
"--include_source_info" `
|
||||
"--descriptor_set_out=$outputPath" `
|
||||
"mxaccess_gateway.proto" `
|
||||
"mxaccess_worker.proto"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "protoc failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if ($Check -and -not (Compare-FileBytes -ExpectedPath $descriptorPath -ActualPath $outputPath)) {
|
||||
throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 and commit the updated descriptor."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($Check -and (Test-Path $outputPath)) {
|
||||
Remove-Item -LiteralPath $outputPath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$NoBuild
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
|
||||
$testProject = Join-Path $repoRoot "src/MxGateway.Tests/MxGateway.Tests.csproj"
|
||||
$arguments = @(
|
||||
"test",
|
||||
$testProject,
|
||||
"--filter",
|
||||
"ClientBehaviorFixtureTests"
|
||||
)
|
||||
|
||||
if ($NoBuild) {
|
||||
$arguments += "--no-build"
|
||||
}
|
||||
|
||||
& dotnet @arguments
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Client behavior fixture validation failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace MxGateway.Contracts;
|
||||
/// </summary>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
public const uint GatewayProtocolVersion = 1;
|
||||
|
||||
public const uint WorkerProtocolVersion = 1;
|
||||
|
||||
public const string DefaultBackendName = "mxaccess-worker";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
// <auto-generated>
|
||||
// Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
// source: mxaccess_gateway.proto
|
||||
// </auto-generated>
|
||||
#pragma warning disable 0414, 1591, 8981, 0612
|
||||
#region Designer generated code
|
||||
|
||||
using grpc = global::Grpc.Core;
|
||||
|
||||
namespace MxGateway.Contracts.Proto {
|
||||
/// <summary>
|
||||
/// Public client API for MXAccess sessions hosted by the gateway.
|
||||
/// </summary>
|
||||
public static partial class MxAccessGateway
|
||||
{
|
||||
static readonly string __ServiceName = "mxaccess_gateway.v1.MxAccessGateway";
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context)
|
||||
{
|
||||
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||
if (message is global::Google.Protobuf.IBufferMessage)
|
||||
{
|
||||
context.SetPayloadLength(message.CalculateSize());
|
||||
global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter());
|
||||
context.Complete();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static class __Helper_MessageCache<T>
|
||||
{
|
||||
public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static T __Helper_DeserializeMessage<T>(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser<T> parser) where T : global::Google.Protobuf.IMessage<T>
|
||||
{
|
||||
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
|
||||
if (__Helper_MessageCache<T>.IsBufferMessage)
|
||||
{
|
||||
return parser.ParseFrom(context.PayloadAsReadOnlySequence());
|
||||
}
|
||||
#endif
|
||||
return parser.ParseFrom(context.PayloadAsNewBuffer());
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.OpenSessionRequest> __Marshaller_mxaccess_gateway_v1_OpenSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.OpenSessionReply> __Marshaller_mxaccess_gateway_v1_OpenSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.OpenSessionReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.CloseSessionRequest> __Marshaller_mxaccess_gateway_v1_CloseSessionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.CloseSessionReply> __Marshaller_mxaccess_gateway_v1_CloseSessionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.CloseSessionReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.MxCommandRequest> __Marshaller_mxaccess_gateway_v1_MxCommandRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxCommandRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.MxCommandReply> __Marshaller_mxaccess_gateway_v1_MxCommandReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxCommandReply.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.StreamEventsRequest> __Marshaller_mxaccess_gateway_v1_StreamEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser));
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.MxEvent> __Marshaller_mxaccess_gateway_v1_MxEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxEvent.Parser));
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply> __Method_OpenSession = new grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"OpenSession",
|
||||
__Marshaller_mxaccess_gateway_v1_OpenSessionRequest,
|
||||
__Marshaller_mxaccess_gateway_v1_OpenSessionReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.CloseSessionRequest, global::MxGateway.Contracts.Proto.CloseSessionReply> __Method_CloseSession = new grpc::Method<global::MxGateway.Contracts.Proto.CloseSessionRequest, global::MxGateway.Contracts.Proto.CloseSessionReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"CloseSession",
|
||||
__Marshaller_mxaccess_gateway_v1_CloseSessionRequest,
|
||||
__Marshaller_mxaccess_gateway_v1_CloseSessionReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply> __Method_Invoke = new grpc::Method<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply>(
|
||||
grpc::MethodType.Unary,
|
||||
__ServiceName,
|
||||
"Invoke",
|
||||
__Marshaller_mxaccess_gateway_v1_MxCommandRequest,
|
||||
__Marshaller_mxaccess_gateway_v1_MxCommandReply);
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
static readonly grpc::Method<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent> __Method_StreamEvents = new grpc::Method<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent>(
|
||||
grpc::MethodType.ServerStreaming,
|
||||
__ServiceName,
|
||||
"StreamEvents",
|
||||
__Marshaller_mxaccess_gateway_v1_StreamEventsRequest,
|
||||
__Marshaller_mxaccess_gateway_v1_MxEvent);
|
||||
|
||||
/// <summary>Service descriptor</summary>
|
||||
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
|
||||
{
|
||||
get { return global::MxGateway.Contracts.Proto.MxaccessGatewayReflection.Descriptor.Services[0]; }
|
||||
}
|
||||
|
||||
/// <summary>Base class for server-side implementations of MxAccessGateway</summary>
|
||||
[grpc::BindServiceMethod(typeof(MxAccessGateway), "BindService")]
|
||||
public abstract partial class MxAccessGatewayBase
|
||||
{
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.OpenSessionReply> OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.CloseSessionReply> CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.MxCommandReply> Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::System.Threading.Tasks.Task StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.MxEvent> responseStream, grpc::ServerCallContext context)
|
||||
{
|
||||
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>Client for MxAccessGateway</summary>
|
||||
public partial class MxAccessGatewayClient : grpc::ClientBase<MxAccessGatewayClient>
|
||||
{
|
||||
/// <summary>Creates a new client for MxAccessGateway</summary>
|
||||
/// <param name="channel">The channel to use to make remote calls.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public MxAccessGatewayClient(grpc::ChannelBase channel) : base(channel)
|
||||
{
|
||||
}
|
||||
/// <summary>Creates a new client for MxAccessGateway that uses a custom <c>CallInvoker</c>.</summary>
|
||||
/// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public MxAccessGatewayClient(grpc::CallInvoker callInvoker) : base(callInvoker)
|
||||
{
|
||||
}
|
||||
/// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected MxAccessGatewayClient() : base()
|
||||
{
|
||||
}
|
||||
/// <summary>Protected constructor to allow creation of configured clients.</summary>
|
||||
/// <param name="configuration">The client configuration.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected MxAccessGatewayClient(ClientBaseConfiguration configuration) : base(configuration)
|
||||
{
|
||||
}
|
||||
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return OpenSession(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.OpenSessionReply OpenSession(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_OpenSession, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.OpenSessionReply> OpenSessionAsync(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return OpenSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.OpenSessionReply> OpenSessionAsync(global::MxGateway.Contracts.Proto.OpenSessionRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_OpenSession, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return CloseSession(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.CloseSessionReply CloseSession(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_CloseSession, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.CloseSessionReply> CloseSessionAsync(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return CloseSessionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.CloseSessionReply> CloseSessionAsync(global::MxGateway.Contracts.Proto.CloseSessionRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_CloseSession, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.MxCommandReply Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return Invoke(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual global::MxGateway.Contracts.Proto.MxCommandReply Invoke(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.BlockingUnaryCall(__Method_Invoke, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.MxCommandReply> InvokeAsync(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return InvokeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.MxCommandReply> InvokeAsync(global::MxGateway.Contracts.Proto.MxCommandRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncUnaryCall(__Method_Invoke, null, options, request);
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.MxEvent> StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
|
||||
{
|
||||
return StreamEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken));
|
||||
}
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.MxEvent> StreamEvents(global::MxGateway.Contracts.Proto.StreamEventsRequest request, grpc::CallOptions options)
|
||||
{
|
||||
return CallInvoker.AsyncServerStreamingCall(__Method_StreamEvents, null, options, request);
|
||||
}
|
||||
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
protected override MxAccessGatewayClient NewInstance(ClientBaseConfiguration configuration)
|
||||
{
|
||||
return new MxAccessGatewayClient(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates service definition that can be registered with a server</summary>
|
||||
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public static grpc::ServerServiceDefinition BindService(MxAccessGatewayBase serviceImpl)
|
||||
{
|
||||
return grpc::ServerServiceDefinition.CreateBuilder()
|
||||
.AddMethod(__Method_OpenSession, serviceImpl.OpenSession)
|
||||
.AddMethod(__Method_CloseSession, serviceImpl.CloseSession)
|
||||
.AddMethod(__Method_Invoke, serviceImpl.Invoke)
|
||||
.AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents).Build();
|
||||
}
|
||||
|
||||
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
|
||||
/// Note: this method is part of an experimental API that can change or be removed without any prior notice.</summary>
|
||||
/// <param name="serviceBinder">Service methods will be bound by calling <c>AddMethod</c> on this object.</param>
|
||||
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
|
||||
public static void BindService(grpc::ServiceBinderBase serviceBinder, MxAccessGatewayBase serviceImpl)
|
||||
{
|
||||
serviceBinder.AddMethod(__Method_OpenSession, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply>(serviceImpl.OpenSession));
|
||||
serviceBinder.AddMethod(__Method_CloseSession, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.CloseSessionRequest, global::MxGateway.Contracts.Proto.CloseSessionReply>(serviceImpl.CloseSession));
|
||||
serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply>(serviceImpl.Invoke));
|
||||
serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent>(serviceImpl.StreamEvents));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Generated\**\*.cs" />
|
||||
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||
<Protobuf Include="Protos\mxaccess_worker.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcServices="None" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageReference Include="Grpc.Core.Api" Version="2.76.0" />
|
||||
<PackageReference Include="Grpc.Tools" Version="2.80.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="6.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mxaccess_gateway.v1;
|
||||
|
||||
option csharp_namespace = "MxGateway.Contracts.Proto";
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Public client API for MXAccess sessions hosted by the gateway.
|
||||
service MxAccessGateway {
|
||||
rpc OpenSession(OpenSessionRequest) returns (OpenSessionReply);
|
||||
rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
|
||||
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
|
||||
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
|
||||
}
|
||||
|
||||
message OpenSessionRequest {
|
||||
string requested_backend = 1;
|
||||
string client_session_name = 2;
|
||||
string client_correlation_id = 3;
|
||||
google.protobuf.Duration command_timeout = 4;
|
||||
}
|
||||
|
||||
message OpenSessionReply {
|
||||
string session_id = 1;
|
||||
string backend_name = 2;
|
||||
int32 worker_process_id = 3;
|
||||
uint32 worker_protocol_version = 4;
|
||||
repeated string capabilities = 5;
|
||||
google.protobuf.Duration default_command_timeout = 6;
|
||||
ProtocolStatus protocol_status = 7;
|
||||
// Public gateway contract version implemented by this endpoint. Clients use
|
||||
// this value to reject incompatible generated-code inputs before issuing
|
||||
// command-specific MXAccess calls.
|
||||
uint32 gateway_protocol_version = 8;
|
||||
}
|
||||
|
||||
message CloseSessionRequest {
|
||||
string session_id = 1;
|
||||
string client_correlation_id = 2;
|
||||
}
|
||||
|
||||
message CloseSessionReply {
|
||||
string session_id = 1;
|
||||
SessionState final_state = 2;
|
||||
ProtocolStatus protocol_status = 3;
|
||||
}
|
||||
|
||||
message StreamEventsRequest {
|
||||
string session_id = 1;
|
||||
uint64 after_worker_sequence = 2;
|
||||
}
|
||||
|
||||
message MxCommandRequest {
|
||||
string session_id = 1;
|
||||
string client_correlation_id = 2;
|
||||
MxCommand command = 3;
|
||||
}
|
||||
|
||||
message MxCommand {
|
||||
MxCommandKind kind = 1;
|
||||
|
||||
oneof payload {
|
||||
RegisterCommand register = 10;
|
||||
UnregisterCommand unregister = 11;
|
||||
AddItemCommand add_item = 12;
|
||||
AddItem2Command add_item2 = 13;
|
||||
RemoveItemCommand remove_item = 14;
|
||||
AdviseCommand advise = 15;
|
||||
UnAdviseCommand un_advise = 16;
|
||||
AdviseSupervisoryCommand advise_supervisory = 17;
|
||||
AddBufferedItemCommand add_buffered_item = 18;
|
||||
SetBufferedUpdateIntervalCommand set_buffered_update_interval = 19;
|
||||
SuspendCommand suspend = 20;
|
||||
ActivateCommand activate = 21;
|
||||
WriteCommand write = 22;
|
||||
Write2Command write2 = 23;
|
||||
WriteSecuredCommand write_secured = 24;
|
||||
WriteSecured2Command write_secured2 = 25;
|
||||
AuthenticateUserCommand authenticate_user = 26;
|
||||
ArchestrAUserToIdCommand archestra_user_to_id = 27;
|
||||
PingCommand ping = 100;
|
||||
GetSessionStateCommand get_session_state = 101;
|
||||
GetWorkerInfoCommand get_worker_info = 102;
|
||||
DrainEventsCommand drain_events = 103;
|
||||
ShutdownWorkerCommand shutdown_worker = 104;
|
||||
}
|
||||
}
|
||||
|
||||
enum MxCommandKind {
|
||||
MX_COMMAND_KIND_UNSPECIFIED = 0;
|
||||
MX_COMMAND_KIND_REGISTER = 1;
|
||||
MX_COMMAND_KIND_UNREGISTER = 2;
|
||||
MX_COMMAND_KIND_ADD_ITEM = 3;
|
||||
MX_COMMAND_KIND_ADD_ITEM2 = 4;
|
||||
MX_COMMAND_KIND_REMOVE_ITEM = 5;
|
||||
MX_COMMAND_KIND_ADVISE = 6;
|
||||
MX_COMMAND_KIND_UN_ADVISE = 7;
|
||||
MX_COMMAND_KIND_ADVISE_SUPERVISORY = 8;
|
||||
MX_COMMAND_KIND_ADD_BUFFERED_ITEM = 9;
|
||||
MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL = 10;
|
||||
MX_COMMAND_KIND_SUSPEND = 11;
|
||||
MX_COMMAND_KIND_ACTIVATE = 12;
|
||||
MX_COMMAND_KIND_WRITE = 13;
|
||||
MX_COMMAND_KIND_WRITE2 = 14;
|
||||
MX_COMMAND_KIND_WRITE_SECURED = 15;
|
||||
MX_COMMAND_KIND_WRITE_SECURED2 = 16;
|
||||
MX_COMMAND_KIND_AUTHENTICATE_USER = 17;
|
||||
MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID = 18;
|
||||
MX_COMMAND_KIND_PING = 100;
|
||||
MX_COMMAND_KIND_GET_SESSION_STATE = 101;
|
||||
MX_COMMAND_KIND_GET_WORKER_INFO = 102;
|
||||
MX_COMMAND_KIND_DRAIN_EVENTS = 103;
|
||||
MX_COMMAND_KIND_SHUTDOWN_WORKER = 104;
|
||||
}
|
||||
|
||||
message RegisterCommand {
|
||||
string client_name = 1;
|
||||
}
|
||||
|
||||
message UnregisterCommand {
|
||||
int32 server_handle = 1;
|
||||
}
|
||||
|
||||
message AddItemCommand {
|
||||
int32 server_handle = 1;
|
||||
string item_definition = 2;
|
||||
}
|
||||
|
||||
message AddItem2Command {
|
||||
int32 server_handle = 1;
|
||||
string item_definition = 2;
|
||||
string item_context = 3;
|
||||
}
|
||||
|
||||
message RemoveItemCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message AdviseCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message UnAdviseCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message AdviseSupervisoryCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message AddBufferedItemCommand {
|
||||
int32 server_handle = 1;
|
||||
string item_definition = 2;
|
||||
string item_context = 3;
|
||||
}
|
||||
|
||||
message SetBufferedUpdateIntervalCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 update_interval_milliseconds = 2;
|
||||
}
|
||||
|
||||
message SuspendCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message ActivateCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
}
|
||||
|
||||
message WriteCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
MxValue value = 3;
|
||||
int32 user_id = 4;
|
||||
}
|
||||
|
||||
message Write2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
MxValue value = 3;
|
||||
MxValue timestamp_value = 4;
|
||||
int32 user_id = 5;
|
||||
}
|
||||
|
||||
message WriteSecuredCommand {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
int32 current_user_id = 3;
|
||||
int32 verifier_user_id = 4;
|
||||
// Credential-sensitive write value. Implementations must not log this field
|
||||
// unless an explicit redacted value-logging path is enabled.
|
||||
MxValue value = 5;
|
||||
}
|
||||
|
||||
message WriteSecured2Command {
|
||||
int32 server_handle = 1;
|
||||
int32 item_handle = 2;
|
||||
int32 current_user_id = 3;
|
||||
int32 verifier_user_id = 4;
|
||||
// Credential-sensitive write value. Implementations must not log this field
|
||||
// unless an explicit redacted value-logging path is enabled.
|
||||
MxValue value = 5;
|
||||
MxValue timestamp_value = 6;
|
||||
}
|
||||
|
||||
message AuthenticateUserCommand {
|
||||
int32 server_handle = 1;
|
||||
string verify_user = 2;
|
||||
// Raw MXAccess credential. Implementations must keep this field out of logs,
|
||||
// metrics labels, command lines, and diagnostics.
|
||||
string verify_user_password = 3;
|
||||
}
|
||||
|
||||
message ArchestrAUserToIdCommand {
|
||||
int32 server_handle = 1;
|
||||
string user_id_guid = 2;
|
||||
}
|
||||
|
||||
message PingCommand {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message GetSessionStateCommand {
|
||||
}
|
||||
|
||||
message GetWorkerInfoCommand {
|
||||
}
|
||||
|
||||
message DrainEventsCommand {
|
||||
uint32 max_events = 1;
|
||||
}
|
||||
|
||||
message ShutdownWorkerCommand {
|
||||
google.protobuf.Duration grace_period = 1;
|
||||
}
|
||||
|
||||
message MxCommandReply {
|
||||
string session_id = 1;
|
||||
string correlation_id = 2;
|
||||
MxCommandKind kind = 3;
|
||||
ProtocolStatus protocol_status = 4;
|
||||
// HRESULT captured from MXAccess or a COM exception. This remains separate
|
||||
// from gateway protocol status so MXAccess parity details are not hidden by
|
||||
// transport failures.
|
||||
optional int32 hresult = 5;
|
||||
MxValue return_value = 6;
|
||||
repeated MxStatusProxy statuses = 7;
|
||||
string diagnostic_message = 8;
|
||||
|
||||
oneof payload {
|
||||
RegisterReply register = 20;
|
||||
AddItemReply add_item = 21;
|
||||
AddItem2Reply add_item2 = 22;
|
||||
AddBufferedItemReply add_buffered_item = 23;
|
||||
SuspendReply suspend = 24;
|
||||
ActivateReply activate = 25;
|
||||
AuthenticateUserReply authenticate_user = 26;
|
||||
ArchestrAUserToIdReply archestra_user_to_id = 27;
|
||||
SessionStateReply session_state = 100;
|
||||
WorkerInfoReply worker_info = 101;
|
||||
DrainEventsReply drain_events = 102;
|
||||
}
|
||||
}
|
||||
|
||||
message RegisterReply {
|
||||
int32 server_handle = 1;
|
||||
}
|
||||
|
||||
message AddItemReply {
|
||||
int32 item_handle = 1;
|
||||
}
|
||||
|
||||
message AddItem2Reply {
|
||||
int32 item_handle = 1;
|
||||
}
|
||||
|
||||
message AddBufferedItemReply {
|
||||
int32 item_handle = 1;
|
||||
}
|
||||
|
||||
message SuspendReply {
|
||||
MxStatusProxy status = 1;
|
||||
}
|
||||
|
||||
message ActivateReply {
|
||||
MxStatusProxy status = 1;
|
||||
}
|
||||
|
||||
message AuthenticateUserReply {
|
||||
int32 user_id = 1;
|
||||
}
|
||||
|
||||
message ArchestrAUserToIdReply {
|
||||
int32 user_id = 1;
|
||||
}
|
||||
|
||||
message SessionStateReply {
|
||||
SessionState state = 1;
|
||||
}
|
||||
|
||||
message WorkerInfoReply {
|
||||
int32 worker_process_id = 1;
|
||||
string worker_version = 2;
|
||||
string mxaccess_progid = 3;
|
||||
string mxaccess_clsid = 4;
|
||||
}
|
||||
|
||||
message DrainEventsReply {
|
||||
repeated MxEvent events = 1;
|
||||
}
|
||||
|
||||
message MxEvent {
|
||||
MxEventFamily family = 1;
|
||||
string session_id = 2;
|
||||
int32 server_handle = 3;
|
||||
int32 item_handle = 4;
|
||||
MxValue value = 5;
|
||||
int32 quality = 6;
|
||||
google.protobuf.Timestamp source_timestamp = 7;
|
||||
repeated MxStatusProxy statuses = 8;
|
||||
uint64 worker_sequence = 9;
|
||||
google.protobuf.Timestamp worker_timestamp = 10;
|
||||
google.protobuf.Timestamp gateway_receive_timestamp = 11;
|
||||
optional int32 hresult = 12;
|
||||
string raw_status = 13;
|
||||
|
||||
oneof body {
|
||||
OnDataChangeEvent on_data_change = 20;
|
||||
OnWriteCompleteEvent on_write_complete = 21;
|
||||
OperationCompleteEvent operation_complete = 22;
|
||||
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
||||
}
|
||||
}
|
||||
|
||||
enum MxEventFamily {
|
||||
MX_EVENT_FAMILY_UNSPECIFIED = 0;
|
||||
MX_EVENT_FAMILY_ON_DATA_CHANGE = 1;
|
||||
MX_EVENT_FAMILY_ON_WRITE_COMPLETE = 2;
|
||||
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
||||
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
||||
}
|
||||
|
||||
message OnDataChangeEvent {
|
||||
}
|
||||
|
||||
message OnWriteCompleteEvent {
|
||||
}
|
||||
|
||||
message OperationCompleteEvent {
|
||||
}
|
||||
|
||||
message OnBufferedDataChangeEvent {
|
||||
MxDataType data_type = 1;
|
||||
MxArray quality_values = 2;
|
||||
MxArray timestamp_values = 3;
|
||||
int32 raw_data_type = 4;
|
||||
}
|
||||
|
||||
message MxStatusProxy {
|
||||
int32 success = 1;
|
||||
MxStatusCategory category = 2;
|
||||
MxStatusSource detected_by = 3;
|
||||
int32 detail = 4;
|
||||
int32 raw_category = 5;
|
||||
int32 raw_detected_by = 6;
|
||||
string diagnostic_text = 7;
|
||||
}
|
||||
|
||||
enum MxStatusCategory {
|
||||
MX_STATUS_CATEGORY_UNSPECIFIED = 0;
|
||||
MX_STATUS_CATEGORY_UNKNOWN = 1;
|
||||
MX_STATUS_CATEGORY_OK = 2;
|
||||
MX_STATUS_CATEGORY_PENDING = 3;
|
||||
MX_STATUS_CATEGORY_WARNING = 4;
|
||||
MX_STATUS_CATEGORY_COMMUNICATION_ERROR = 5;
|
||||
MX_STATUS_CATEGORY_CONFIGURATION_ERROR = 6;
|
||||
MX_STATUS_CATEGORY_OPERATIONAL_ERROR = 7;
|
||||
MX_STATUS_CATEGORY_SECURITY_ERROR = 8;
|
||||
MX_STATUS_CATEGORY_SOFTWARE_ERROR = 9;
|
||||
MX_STATUS_CATEGORY_OTHER_ERROR = 10;
|
||||
}
|
||||
|
||||
enum MxStatusSource {
|
||||
MX_STATUS_SOURCE_UNSPECIFIED = 0;
|
||||
MX_STATUS_SOURCE_UNKNOWN = 1;
|
||||
MX_STATUS_SOURCE_REQUESTING_LMX = 2;
|
||||
MX_STATUS_SOURCE_RESPONDING_LMX = 3;
|
||||
MX_STATUS_SOURCE_REQUESTING_NMX = 4;
|
||||
MX_STATUS_SOURCE_RESPONDING_NMX = 5;
|
||||
MX_STATUS_SOURCE_REQUESTING_AUTOMATION_OBJECT = 6;
|
||||
MX_STATUS_SOURCE_RESPONDING_AUTOMATION_OBJECT = 7;
|
||||
}
|
||||
|
||||
message MxValue {
|
||||
MxDataType data_type = 1;
|
||||
string variant_type = 2;
|
||||
bool is_null = 3;
|
||||
string raw_diagnostic = 4;
|
||||
int32 raw_data_type = 5;
|
||||
|
||||
oneof kind {
|
||||
bool bool_value = 10;
|
||||
int32 int32_value = 11;
|
||||
int64 int64_value = 12;
|
||||
float float_value = 13;
|
||||
double double_value = 14;
|
||||
string string_value = 15;
|
||||
google.protobuf.Timestamp timestamp_value = 16;
|
||||
MxArray array_value = 17;
|
||||
bytes raw_value = 18;
|
||||
}
|
||||
}
|
||||
|
||||
message MxArray {
|
||||
MxDataType element_data_type = 1;
|
||||
string variant_type = 2;
|
||||
repeated uint32 dimensions = 3;
|
||||
string raw_diagnostic = 4;
|
||||
int32 raw_element_data_type = 5;
|
||||
|
||||
oneof values {
|
||||
BoolArray bool_values = 10;
|
||||
Int32Array int32_values = 11;
|
||||
Int64Array int64_values = 12;
|
||||
FloatArray float_values = 13;
|
||||
DoubleArray double_values = 14;
|
||||
StringArray string_values = 15;
|
||||
TimestampArray timestamp_values = 16;
|
||||
RawArray raw_values = 17;
|
||||
}
|
||||
}
|
||||
|
||||
message BoolArray {
|
||||
repeated bool values = 1;
|
||||
}
|
||||
|
||||
message Int32Array {
|
||||
repeated int32 values = 1;
|
||||
}
|
||||
|
||||
message Int64Array {
|
||||
repeated int64 values = 1;
|
||||
}
|
||||
|
||||
message FloatArray {
|
||||
repeated float values = 1;
|
||||
}
|
||||
|
||||
message DoubleArray {
|
||||
repeated double values = 1;
|
||||
}
|
||||
|
||||
message StringArray {
|
||||
repeated string values = 1;
|
||||
}
|
||||
|
||||
message TimestampArray {
|
||||
repeated google.protobuf.Timestamp values = 1;
|
||||
}
|
||||
|
||||
message RawArray {
|
||||
repeated bytes values = 1;
|
||||
}
|
||||
|
||||
enum MxDataType {
|
||||
MX_DATA_TYPE_UNSPECIFIED = 0;
|
||||
MX_DATA_TYPE_UNKNOWN = 1;
|
||||
MX_DATA_TYPE_NO_DATA = 2;
|
||||
MX_DATA_TYPE_BOOLEAN = 3;
|
||||
MX_DATA_TYPE_INTEGER = 4;
|
||||
MX_DATA_TYPE_FLOAT = 5;
|
||||
MX_DATA_TYPE_DOUBLE = 6;
|
||||
MX_DATA_TYPE_STRING = 7;
|
||||
MX_DATA_TYPE_TIME = 8;
|
||||
MX_DATA_TYPE_ELAPSED_TIME = 9;
|
||||
MX_DATA_TYPE_REFERENCE_TYPE = 10;
|
||||
MX_DATA_TYPE_STATUS_TYPE = 11;
|
||||
MX_DATA_TYPE_ENUM = 12;
|
||||
MX_DATA_TYPE_SECURITY_CLASSIFICATION_ENUM = 13;
|
||||
MX_DATA_TYPE_DATA_QUALITY_TYPE = 14;
|
||||
MX_DATA_TYPE_QUALIFIED_ENUM = 15;
|
||||
MX_DATA_TYPE_QUALIFIED_STRUCT = 16;
|
||||
MX_DATA_TYPE_INTERNATIONALIZED_STRING = 17;
|
||||
MX_DATA_TYPE_BIG_STRING = 18;
|
||||
MX_DATA_TYPE_END = 19;
|
||||
}
|
||||
|
||||
message ProtocolStatus {
|
||||
ProtocolStatusCode code = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
enum ProtocolStatusCode {
|
||||
PROTOCOL_STATUS_CODE_UNSPECIFIED = 0;
|
||||
PROTOCOL_STATUS_CODE_OK = 1;
|
||||
PROTOCOL_STATUS_CODE_INVALID_REQUEST = 2;
|
||||
PROTOCOL_STATUS_CODE_SESSION_NOT_FOUND = 3;
|
||||
PROTOCOL_STATUS_CODE_SESSION_NOT_READY = 4;
|
||||
PROTOCOL_STATUS_CODE_WORKER_UNAVAILABLE = 5;
|
||||
PROTOCOL_STATUS_CODE_TIMEOUT = 6;
|
||||
PROTOCOL_STATUS_CODE_CANCELED = 7;
|
||||
PROTOCOL_STATUS_CODE_PROTOCOL_VIOLATION = 8;
|
||||
PROTOCOL_STATUS_CODE_MXACCESS_FAILURE = 9;
|
||||
}
|
||||
|
||||
enum SessionState {
|
||||
SESSION_STATE_UNSPECIFIED = 0;
|
||||
SESSION_STATE_CREATING = 1;
|
||||
SESSION_STATE_STARTING_WORKER = 2;
|
||||
SESSION_STATE_WAITING_FOR_PIPE = 3;
|
||||
SESSION_STATE_HANDSHAKING = 4;
|
||||
SESSION_STATE_INITIALIZING_WORKER = 5;
|
||||
SESSION_STATE_READY = 6;
|
||||
SESSION_STATE_CLOSING = 7;
|
||||
SESSION_STATE_CLOSED = 8;
|
||||
SESSION_STATE_FAULTED = 9;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package mxaccess_worker.v1;
|
||||
|
||||
option csharp_namespace = "MxGateway.Contracts.Proto";
|
||||
|
||||
import "google/protobuf/duration.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "mxaccess_gateway.proto";
|
||||
|
||||
// Gateway-to-worker IPC envelope. Named-pipe framing prepends a little-endian
|
||||
// uint32 payload length to this protobuf payload.
|
||||
message WorkerEnvelope {
|
||||
uint32 protocol_version = 1;
|
||||
string session_id = 2;
|
||||
uint64 sequence = 3;
|
||||
string correlation_id = 4;
|
||||
|
||||
oneof body {
|
||||
GatewayHello gateway_hello = 10;
|
||||
WorkerHello worker_hello = 11;
|
||||
WorkerReady worker_ready = 12;
|
||||
WorkerCommand worker_command = 13;
|
||||
WorkerCommandReply worker_command_reply = 14;
|
||||
WorkerCancel worker_cancel = 15;
|
||||
WorkerShutdown worker_shutdown = 16;
|
||||
WorkerShutdownAck worker_shutdown_ack = 17;
|
||||
WorkerEvent worker_event = 18;
|
||||
WorkerHeartbeat worker_heartbeat = 19;
|
||||
WorkerFault worker_fault = 20;
|
||||
}
|
||||
}
|
||||
|
||||
message GatewayHello {
|
||||
uint32 supported_protocol_version = 1;
|
||||
string nonce = 2;
|
||||
string gateway_version = 3;
|
||||
}
|
||||
|
||||
message WorkerHello {
|
||||
uint32 protocol_version = 1;
|
||||
string nonce = 2;
|
||||
int32 worker_process_id = 3;
|
||||
string worker_version = 4;
|
||||
}
|
||||
|
||||
message WorkerReady {
|
||||
int32 worker_process_id = 1;
|
||||
string mxaccess_progid = 2;
|
||||
string mxaccess_clsid = 3;
|
||||
google.protobuf.Timestamp ready_timestamp = 4;
|
||||
}
|
||||
|
||||
message WorkerCommand {
|
||||
mxaccess_gateway.v1.MxCommand command = 1;
|
||||
google.protobuf.Timestamp enqueue_timestamp = 2;
|
||||
}
|
||||
|
||||
message WorkerCommandReply {
|
||||
mxaccess_gateway.v1.MxCommandReply reply = 1;
|
||||
google.protobuf.Timestamp completed_timestamp = 2;
|
||||
}
|
||||
|
||||
message WorkerCancel {
|
||||
string reason = 1;
|
||||
}
|
||||
|
||||
message WorkerShutdown {
|
||||
google.protobuf.Duration grace_period = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
message WorkerShutdownAck {
|
||||
mxaccess_gateway.v1.ProtocolStatus status = 1;
|
||||
}
|
||||
|
||||
message WorkerEvent {
|
||||
mxaccess_gateway.v1.MxEvent event = 1;
|
||||
}
|
||||
|
||||
message WorkerHeartbeat {
|
||||
int32 worker_process_id = 1;
|
||||
WorkerState state = 2;
|
||||
google.protobuf.Timestamp last_sta_activity_timestamp = 3;
|
||||
uint32 pending_command_count = 4;
|
||||
uint32 outbound_event_queue_depth = 5;
|
||||
uint64 last_event_sequence = 6;
|
||||
string current_command_correlation_id = 7;
|
||||
}
|
||||
|
||||
message WorkerFault {
|
||||
WorkerFaultCategory category = 1;
|
||||
string command_method = 2;
|
||||
optional int32 hresult = 3;
|
||||
string exception_type = 4;
|
||||
string diagnostic_message = 5;
|
||||
mxaccess_gateway.v1.ProtocolStatus protocol_status = 6;
|
||||
}
|
||||
|
||||
enum WorkerState {
|
||||
WORKER_STATE_UNSPECIFIED = 0;
|
||||
WORKER_STATE_STARTING = 1;
|
||||
WORKER_STATE_HANDSHAKING = 2;
|
||||
WORKER_STATE_INITIALIZING_STA = 3;
|
||||
WORKER_STATE_READY = 4;
|
||||
WORKER_STATE_EXECUTING_COMMAND = 5;
|
||||
WORKER_STATE_SHUTTING_DOWN = 6;
|
||||
WORKER_STATE_STOPPED = 7;
|
||||
WORKER_STATE_FAULTED = 8;
|
||||
}
|
||||
|
||||
enum WorkerFaultCategory {
|
||||
WORKER_FAULT_CATEGORY_UNSPECIFIED = 0;
|
||||
WORKER_FAULT_CATEGORY_INVALID_ARGUMENTS = 1;
|
||||
WORKER_FAULT_CATEGORY_GATEWAY_AUTHENTICATION_FAILED = 2;
|
||||
WORKER_FAULT_CATEGORY_PROTOCOL_MISMATCH = 3;
|
||||
WORKER_FAULT_CATEGORY_PROTOCOL_VIOLATION = 4;
|
||||
WORKER_FAULT_CATEGORY_PIPE_DISCONNECTED = 5;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_CREATION_FAILED = 6;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_COMMAND_FAILED = 7;
|
||||
WORKER_FAULT_CATEGORY_MXACCESS_EVENT_CONVERSION_FAILED = 8;
|
||||
WORKER_FAULT_CATEGORY_STA_HUNG = 9;
|
||||
WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW = 10;
|
||||
WORKER_FAULT_CATEGORY_SHUTDOWN_TIMEOUT = 11;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum AuthenticationMode
|
||||
{
|
||||
ApiKey,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class AuthenticationOptions
|
||||
{
|
||||
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
|
||||
|
||||
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
|
||||
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
|
||||
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class DashboardOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
public string PathBase { get; init; } = "/dashboard";
|
||||
|
||||
public bool RequireAdminScope { get; init; } = true;
|
||||
|
||||
public bool AllowAnonymousLocalhost { get; init; }
|
||||
|
||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||
|
||||
public int RecentFaultLimit { get; init; } = 100;
|
||||
|
||||
public int RecentSessionLimit { get; init; } = 200;
|
||||
|
||||
public bool ShowTagValues { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveAuthenticationConfiguration(
|
||||
string Mode,
|
||||
string SqlitePath,
|
||||
string PepperSecretName,
|
||||
bool RunMigrationsOnStartup);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveDashboardConfiguration(
|
||||
bool Enabled,
|
||||
string PathBase,
|
||||
bool RequireAdminScope,
|
||||
bool AllowAnonymousLocalhost,
|
||||
int SnapshotIntervalMilliseconds,
|
||||
int RecentFaultLimit,
|
||||
int RecentSessionLimit,
|
||||
bool ShowTagValues);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveEventConfiguration(
|
||||
int QueueCapacity,
|
||||
string BackpressurePolicy);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveGatewayConfiguration(
|
||||
EffectiveAuthenticationConfiguration Authentication,
|
||||
EffectiveWorkerConfiguration Worker,
|
||||
EffectiveSessionConfiguration Sessions,
|
||||
EffectiveEventConfiguration Events,
|
||||
EffectiveDashboardConfiguration Dashboard,
|
||||
EffectiveProtocolConfiguration Protocol);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveSessionConfiguration(
|
||||
int DefaultCommandTimeoutSeconds,
|
||||
int MaxSessions,
|
||||
bool AllowMultipleEventSubscribers);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveWorkerConfiguration(
|
||||
string ExecutablePath,
|
||||
string? WorkingDirectory,
|
||||
string RequiredArchitecture,
|
||||
int StartupTimeoutSeconds,
|
||||
int ShutdownTimeoutSeconds,
|
||||
int HeartbeatIntervalSeconds,
|
||||
int HeartbeatGraceSeconds,
|
||||
int MaxMessageBytes);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum EventBackpressurePolicy
|
||||
{
|
||||
FailFast
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class EventOptions
|
||||
{
|
||||
public int QueueCapacity { get; init; } = 10_000;
|
||||
|
||||
public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider
|
||||
{
|
||||
public const string RedactedValue = "[redacted]";
|
||||
|
||||
public EffectiveGatewayConfiguration GetEffectiveConfiguration()
|
||||
{
|
||||
GatewayOptions value = options.Value;
|
||||
|
||||
return new EffectiveGatewayConfiguration(
|
||||
Authentication: new EffectiveAuthenticationConfiguration(
|
||||
Mode: value.Authentication.Mode.ToString(),
|
||||
SqlitePath: value.Authentication.SqlitePath,
|
||||
PepperSecretName: RedactedValue,
|
||||
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
RequiredArchitecture: value.Worker.RequiredArchitecture.ToString(),
|
||||
StartupTimeoutSeconds: value.Worker.StartupTimeoutSeconds,
|
||||
ShutdownTimeoutSeconds: value.Worker.ShutdownTimeoutSeconds,
|
||||
HeartbeatIntervalSeconds: value.Worker.HeartbeatIntervalSeconds,
|
||||
HeartbeatGraceSeconds: value.Worker.HeartbeatGraceSeconds,
|
||||
MaxMessageBytes: value.Worker.MaxMessageBytes),
|
||||
Sessions: new EffectiveSessionConfiguration(
|
||||
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
|
||||
MaxSessions: value.Sessions.MaxSessions,
|
||||
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
|
||||
Events: new EffectiveEventConfiguration(
|
||||
QueueCapacity: value.Events.QueueCapacity,
|
||||
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
|
||||
Dashboard: new EffectiveDashboardConfiguration(
|
||||
Enabled: value.Dashboard.Enabled,
|
||||
PathBase: value.Dashboard.PathBase,
|
||||
RequireAdminScope: value.Dashboard.RequireAdminScope,
|
||||
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
|
||||
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public static class GatewayConfigurationServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddOptions<GatewayOptions>()
|
||||
.BindConfiguration(GatewayOptions.SectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string SectionName = "MxGateway";
|
||||
|
||||
public AuthenticationOptions Authentication { get; init; } = new();
|
||||
|
||||
public WorkerOptions Worker { get; init; } = new();
|
||||
|
||||
public SessionOptions Sessions { get; init; } = new();
|
||||
|
||||
public EventOptions Events { get; init; } = new();
|
||||
|
||||
public DashboardOptions Dashboard { get; init; } = new();
|
||||
|
||||
public ProtocolOptions Protocol { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
{
|
||||
private const int MinimumMaxMessageBytes = 1024;
|
||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||
|
||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
||||
{
|
||||
List<string> failures = [];
|
||||
|
||||
ValidateAuthentication(options.Authentication, failures);
|
||||
ValidateWorker(options.Worker, failures);
|
||||
ValidateSessions(options.Sessions, failures);
|
||||
ValidateEvents(options.Events, failures);
|
||||
ValidateDashboard(options.Dashboard, failures);
|
||||
ValidateProtocol(options.Protocol, failures);
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
|
||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
||||
{
|
||||
if (!Enum.IsDefined(options.Mode))
|
||||
{
|
||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.Mode == AuthenticationMode.ApiKey)
|
||||
{
|
||||
AddIfBlank(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
AddIfInvalidPath(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.PepperSecretName,
|
||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
||||
{
|
||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
||||
AddIfInvalidPath(
|
||||
options.ExecutablePath,
|
||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||
failures);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||
{
|
||||
AddIfInvalidPath(
|
||||
options.WorkingDirectory,
|
||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||
failures);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.StartupTimeoutSeconds,
|
||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.ShutdownTimeoutSeconds,
|
||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatIntervalSeconds,
|
||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatGraceSeconds,
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||
{
|
||||
failures.Add(
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||
}
|
||||
|
||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(
|
||||
options.DefaultCommandTimeoutSeconds,
|
||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
||||
}
|
||||
|
||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
||||
|
||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||
{
|
||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
||||
{
|
||||
if (options.Enabled)
|
||||
{
|
||||
AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures);
|
||||
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
|
||||
{
|
||||
failures.Add("MxGateway:Dashboard:PathBase must start with '/'.");
|
||||
}
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.SnapshotIntervalMilliseconds,
|
||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentFaultLimit,
|
||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentSessionLimit,
|
||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
}
|
||||
|
||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
||||
{
|
||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = Path.GetFullPath(value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public interface IGatewayConfigurationProvider
|
||||
{
|
||||
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using MxGateway.Contracts;
|
||||
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class ProtocolOptions
|
||||
{
|
||||
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class SessionOptions
|
||||
{
|
||||
public int DefaultCommandTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public int MaxSessions { get; init; } = 64;
|
||||
|
||||
public bool AllowMultipleEventSubscribers { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public enum WorkerArchitecture
|
||||
{
|
||||
X86,
|
||||
X64
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
public string ExecutablePath { get; init; } =
|
||||
@"src\MxGateway.Worker\bin\x86\Release\MxGateway.Worker.exe";
|
||||
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86;
|
||||
|
||||
public int StartupTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
public int HeartbeatIntervalSeconds { get; init; } = 5;
|
||||
|
||||
public int HeartbeatGraceSeconds { get; init; } = 15;
|
||||
|
||||
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<base href="@DashboardBaseHref" />
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private string DashboardBaseHref
|
||||
{
|
||||
get
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MxGateway.Server.Dashboard.Components;
|
||||
|
||||
public static class DashboardDisplay
|
||||
{
|
||||
public static string DateTime(DateTimeOffset? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
public static string Duration(TimeSpan value)
|
||||
{
|
||||
return value.TotalDays >= 1
|
||||
? value.ToString(@"d\.hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string Text(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||
}
|
||||
|
||||
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
|
||||
{
|
||||
return snapshot.Metrics.FirstOrDefault(metric =>
|
||||
string.Equals(metric.Name, name, StringComparison.Ordinal)
|
||||
&& string.Equals(metric.Dimension, dimension, StringComparison.Ordinal))?.Value ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace MxGateway.Server.Dashboard.Components;
|
||||
|
||||
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task? _watchTask;
|
||||
|
||||
[Inject]
|
||||
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
|
||||
|
||||
protected DashboardSnapshot? Snapshot { get; private set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_watchTask = WatchSnapshotsAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
|
||||
if (_watchTask is not null)
|
||||
{
|
||||
await _watchTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_disposeCancellation.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private async Task WatchSnapshotsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (DashboardSnapshot snapshot in SnapshotService
|
||||
.WatchSnapshotsAsync(_disposeCancellation.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
Snapshot = snapshot;
|
||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<nav class="navbar navbar-expand-lg bg-body border-bottom dashboard-navbar">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">MXAccess Gateway</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#dashboardNav"
|
||||
aria-controls="dashboardNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="dashboardNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="sessions">Sessions</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="workers">Workers</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="events">Events</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="settings">Settings</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
<form method="post" action="@DashboardPath("/logout")" class="d-flex">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container-fluid dashboard-content">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string DashboardPath(string relativePath)
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}{relativePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@page "/"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading dashboard snapshot.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Overview</h1>
|
||||
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
|
||||
</div>
|
||||
<StatusBadge Text="@Snapshot.GatewayStatus" />
|
||||
</div>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricCard Label="Uptime" Value="@DashboardDisplay.Duration(Snapshot.GatewayUptime)" Detail="@Snapshot.GatewayVersion" />
|
||||
<MetricCard Label="Open Sessions" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Workers Running" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Faults" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Recent Faults</h2>
|
||||
</div>
|
||||
<FaultList Faults="@Snapshot.Faults" />
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@page "/events"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Events</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading event diagnostics.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Events</h1>
|
||||
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="metric-grid compact">
|
||||
<MetricCard Label="Events Received" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.queue.depth").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected").ToString(System.Globalization.CultureInfo.InvariantCulture)" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Event Families</h2>
|
||||
</div>
|
||||
@if (EventFamilyMetrics.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No event family counters recorded.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Family</th>
|
||||
<th scope="col">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardMetricSummary metric in EventFamilyMetrics)
|
||||
{
|
||||
<tr>
|
||||
<td>@metric.Dimension</td>
|
||||
<td>@metric.Value</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<DashboardMetricSummary> EventFamilyMetrics => Snapshot?.Metrics
|
||||
.Where(metric => metric.Name == "mxgateway.events.received" && metric.Dimension is not null)
|
||||
.OrderBy(metric => metric.Dimension, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@page "/sessions/{SessionId}"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Session</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading session.</div>
|
||||
}
|
||||
else if (CurrentSession is null)
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<h1 class="h4 mb-3">Session Not Found</h1>
|
||||
<p class="mb-0">The session is not present in the current snapshot.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Session Details</h1>
|
||||
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
|
||||
</div>
|
||||
<StatusBadge Text="@CurrentSession.State.ToString()" />
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Session</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
|
||||
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
|
||||
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
|
||||
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
|
||||
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
|
||||
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
|
||||
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
|
||||
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Worker</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
|
||||
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
|
||||
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
|
||||
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@page "/sessions"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Sessions</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading sessions.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Sessions</h1>
|
||||
<div class="text-secondary">@Snapshot.Sessions.Count session rows</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Sessions.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No sessions are active or retained.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Client</th>
|
||||
<th scope="col">Backend</th>
|
||||
<th scope="col">Worker</th>
|
||||
<th scope="col">Opened</th>
|
||||
<th scope="col">Activity</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardSessionSummary session in Snapshot.Sessions)
|
||||
{
|
||||
<tr>
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(session.SessionId)}")"><code>@session.SessionId</code></NavLink></td>
|
||||
<td><StatusBadge Text="@session.State.ToString()" /></td>
|
||||
<td>@DashboardDisplay.Text(session.ClientIdentity)</td>
|
||||
<td>@session.BackendName</td>
|
||||
<td>
|
||||
@(session.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")
|
||||
@if (session.WorkerState is not null)
|
||||
{
|
||||
<span class="ms-1"><StatusBadge Text="@session.WorkerState.ToString()" /></span>
|
||||
}
|
||||
</td>
|
||||
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(session.LastFault)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page "/settings"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Settings</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading settings.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<div class="text-secondary">Effective gateway configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Authentication mode</th><td>@Snapshot.Configuration.Authentication.Mode</td></tr>
|
||||
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
|
||||
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
|
||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
||||
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
||||
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Shutdown timeout</th><td>@Snapshot.Configuration.Worker.ShutdownTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Heartbeat grace</th><td>@Snapshot.Configuration.Worker.HeartbeatGraceSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Default command timeout</th><td>@Snapshot.Configuration.Sessions.DefaultCommandTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Max sessions</th><td>@Snapshot.Configuration.Sessions.MaxSessions</td></tr>
|
||||
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
|
||||
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
|
||||
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
|
||||
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
|
||||
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
|
||||
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
|
||||
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
|
||||
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
|
||||
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@page "/workers"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Workers</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading workers.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Workers</h1>
|
||||
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Workers.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No worker processes are attached.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Process</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
|
||||
{
|
||||
<tr>
|
||||
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||
<td><StatusBadge Text="@worker.State.ToString()" /></td>
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
||||
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(worker.LastFault)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Router AppAssembly="@typeof(Routes).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(DashboardLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(DashboardLayout)">
|
||||
<PageTitle>Dashboard - Not Found</PageTitle>
|
||||
<section class="dashboard-section">
|
||||
<h1 class="h4 mb-3">Not Found</h1>
|
||||
<p class="mb-0">The requested dashboard page does not exist.</p>
|
||||
</section>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,39 @@
|
||||
@if (Faults.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No faults recorded.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Observed</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">Worker</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardFaultSummary fault in Faults)
|
||||
{
|
||||
<tr>
|
||||
<td>@DashboardDisplay.DateTime(fault.ObservedAt)</td>
|
||||
<td>@fault.Source</td>
|
||||
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
|
||||
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||
<td><StatusBadge Text="@fault.State" /></td>
|
||||
<td>@fault.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IReadOnlyList<DashboardFaultSummary> Faults { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<div class="card metric-card h-100">
|
||||
<div class="card-body">
|
||||
<div class="metric-label">@Label</div>
|
||||
<div class="metric-value">@Value</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Detail))
|
||||
{
|
||||
<div class="metric-detail">@Detail</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user