Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Binary file not shown.
@@ -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,26 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"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 @@
|
|||||||
|
|
||||||
@@ -18,6 +18,12 @@ event, value, and status shapes.
|
|||||||
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
|
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
|
||||||
hand-edit generated files.
|
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
|
## Generation
|
||||||
|
|
||||||
Run the contracts build to regenerate C# protobuf and gRPC code:
|
Run the contracts build to regenerate C# protobuf and gRPC code:
|
||||||
@@ -39,8 +45,15 @@ gateway and test projects:
|
|||||||
dotnet build src/MxGateway.sln
|
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
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Client Proto Generation](./client-proto-generation.md)
|
||||||
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
- [Gateway Process Detailed Design](./gateway-process-design.md)
|
||||||
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
|
- [MXAccess Worker Instance Detailed Design](./mxaccess-worker-instance-design.md)
|
||||||
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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,
|
||||||
|
- `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
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -26,6 +26,11 @@ Language-specific plans:
|
|||||||
- `docs/clients-python-design.md`
|
- `docs/clients-python-design.md`
|
||||||
- `docs/clients-java-design.md`
|
- `docs/clients-java-design.md`
|
||||||
|
|
||||||
|
Shared generation inputs:
|
||||||
|
|
||||||
|
- `docs/client-proto-generation.md`
|
||||||
|
- `clients/proto/proto-inputs.json`
|
||||||
|
|
||||||
Language style guides:
|
Language style guides:
|
||||||
|
|
||||||
| Client | Style guide |
|
| Client | Style guide |
|
||||||
@@ -365,6 +370,16 @@ examples/
|
|||||||
Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`.
|
Generated code should be reproducible from `src/MxGateway.Contracts/Protos/`.
|
||||||
Do not hand-edit generated code.
|
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
|
## Versioning
|
||||||
|
|
||||||
All clients should expose:
|
All clients should expose:
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 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,
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- [Protobuf Contracts](./Contracts.md)
|
||||||
|
- [Client Libraries Detailed Design](./client-libraries-design.md)
|
||||||
|
- [Client Libraries Implementation Plan](./implementation-plan-clients.md)
|
||||||
|
- [Protobuf Style Guide](./style-guides/ProtobufStyleGuide.md)
|
||||||
@@ -34,9 +34,13 @@ SignalR circuit. Bootstrap is sufficient for a basic dashboard.
|
|||||||
|
|
||||||
## Hosting Model
|
## 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
|
```text
|
||||||
/dashboard
|
/dashboard
|
||||||
@@ -45,7 +49,7 @@ Suggested endpoint layout:
|
|||||||
/dashboard/workers
|
/dashboard/workers
|
||||||
/dashboard/events
|
/dashboard/events
|
||||||
/dashboard/settings
|
/dashboard/settings
|
||||||
/_blazor
|
/dashboard/_blazor
|
||||||
```
|
```
|
||||||
|
|
||||||
The app should redirect `/` to `/dashboard` only if the deployment wants the
|
The app should redirect `/` to `/dashboard` only if the deployment wants the
|
||||||
@@ -59,9 +63,10 @@ MxGateway.Server
|
|||||||
Components/
|
Components/
|
||||||
App.razor
|
App.razor
|
||||||
Routes.razor
|
Routes.razor
|
||||||
|
DashboardPageBase.cs
|
||||||
|
DashboardDisplay.cs
|
||||||
Layout/
|
Layout/
|
||||||
DashboardLayout.razor
|
DashboardLayout.razor
|
||||||
NavMenu.razor
|
|
||||||
Pages/
|
Pages/
|
||||||
DashboardHome.razor
|
DashboardHome.razor
|
||||||
SessionsPage.razor
|
SessionsPage.razor
|
||||||
@@ -69,26 +74,21 @@ MxGateway.Server
|
|||||||
WorkersPage.razor
|
WorkersPage.razor
|
||||||
EventsPage.razor
|
EventsPage.razor
|
||||||
SettingsPage.razor
|
SettingsPage.razor
|
||||||
Components/
|
Shared/
|
||||||
MetricCard.razor
|
MetricCard.razor
|
||||||
SessionTable.razor
|
StatusBadge.razor
|
||||||
WorkerTable.razor
|
|
||||||
EventRatePanel.razor
|
|
||||||
FaultList.razor
|
FaultList.razor
|
||||||
Services/
|
DashboardSnapshotService.cs
|
||||||
DashboardSnapshotService.cs
|
DashboardAuthorizationHandler.cs
|
||||||
DashboardUpdateHub.cs
|
DashboardAuthenticator.cs
|
||||||
DashboardAuthorization.cs
|
DashboardSnapshot.cs
|
||||||
Models/
|
DashboardSessionSummary.cs
|
||||||
DashboardSnapshot.cs
|
DashboardWorkerSummary.cs
|
||||||
SessionSummary.cs
|
DashboardMetricSummary.cs
|
||||||
WorkerSummary.cs
|
|
||||||
MetricSummary.cs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`DashboardUpdateHub` here means an internal application update service, not a
|
Blazor Server provides the SignalR circuit for UI updates. The implementation
|
||||||
separate public SignalR hub unless implementation proves one is needed. Blazor
|
does not add a separate public dashboard hub.
|
||||||
Server already uses SignalR for UI circuits.
|
|
||||||
|
|
||||||
## Dashboard Data Source
|
## Dashboard Data Source
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ gateway internals.
|
|||||||
|
|
||||||
Use Blazor Server component state updates for real-time dashboard refresh.
|
Use Blazor Server component state updates for real-time dashboard refresh.
|
||||||
|
|
||||||
Recommended pattern:
|
Implemented pattern:
|
||||||
|
|
||||||
1. Page/component subscribes to `WatchSnapshotsAsync`.
|
1. Page/component subscribes to `WatchSnapshotsAsync`.
|
||||||
2. Snapshot service emits updates from a bounded channel or timer.
|
2. Snapshot service emits updates from a bounded channel or timer.
|
||||||
@@ -147,10 +147,8 @@ Recommended pattern:
|
|||||||
|
|
||||||
Default update cadence:
|
Default update cadence:
|
||||||
|
|
||||||
- immediate update on session create/close/fault,
|
|
||||||
- immediate update on worker fault,
|
|
||||||
- periodic metrics refresh every 1 second,
|
- 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
|
Avoid pushing every MXAccess data-change event to the dashboard. Aggregate event
|
||||||
counts and rates instead.
|
counts and rates instead.
|
||||||
@@ -257,19 +255,18 @@ Do not show API key secrets or pepper values.
|
|||||||
|
|
||||||
## Authentication And Authorization
|
## 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.
|
practical.
|
||||||
|
|
||||||
Recommended v1 behavior:
|
Implemented v1 behavior:
|
||||||
|
|
||||||
- dashboard disabled by default unless configured,
|
|
||||||
- when enabled, require API key auth,
|
- when enabled, require API key auth,
|
||||||
- require `admin` scope for dashboard access,
|
- require `admin` scope for dashboard access,
|
||||||
- accept API key through a secure cookie established by a simple login form, or
|
- accept API key through a secure cookie established by a simple login form,
|
||||||
through reverse-proxy/header configuration for local deployments,
|
- do not put API keys in query strings,
|
||||||
- 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`.
|
1. Add `/dashboard/login`.
|
||||||
2. User submits API key over HTTPS.
|
2. User submits API key over HTTPS.
|
||||||
@@ -281,6 +278,13 @@ Simplest implementation path:
|
|||||||
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
|
||||||
option. It must default to false.
|
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
|
## Configuration
|
||||||
|
|
||||||
Suggested configuration:
|
Suggested configuration:
|
||||||
@@ -314,7 +318,9 @@ Suggested configuration:
|
|||||||
|
|
||||||
## Styling
|
## 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:
|
Recommended visual language:
|
||||||
|
|
||||||
@@ -355,15 +361,18 @@ Integration tests should verify:
|
|||||||
|
|
||||||
## Initial Implementation Slice
|
## Initial Implementation Slice
|
||||||
|
|
||||||
The first dashboard slice should implement:
|
The first dashboard slice implements:
|
||||||
|
|
||||||
1. Blazor Server hosting in `MxGateway.Server`.
|
1. Blazor Server hosting in `MxGateway.Server`.
|
||||||
2. Bootstrap static assets.
|
2. local Bootstrap static assets.
|
||||||
3. dashboard configuration binding.
|
3. dashboard configuration binding.
|
||||||
4. dashboard auth using API key login and HTTP-only cookie.
|
4. dashboard auth using API key login and HTTP-only cookie.
|
||||||
5. read-only `DashboardSnapshotService`.
|
5. read-only `DashboardSnapshotService`.
|
||||||
6. home page with metric cards.
|
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.
|
8. workers page with worker table.
|
||||||
9. 1-second realtime refresh through Blazor Server.
|
9. events page with aggregate counters.
|
||||||
10. redaction tests for secrets.
|
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.
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ MxGateway.Server
|
|||||||
Configuration
|
Configuration
|
||||||
Grpc
|
Grpc
|
||||||
MxAccessGatewayService
|
MxAccessGatewayService
|
||||||
RequestReplyMapper
|
MxAccessGrpcRequestValidator
|
||||||
EventMapper
|
MxAccessGrpcMapper
|
||||||
Dashboard
|
Dashboard
|
||||||
Pages
|
Pages
|
||||||
Components
|
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:
|
Add this later only after the command and event model is stable:
|
||||||
|
|
||||||
```protobuf
|
```protobuf
|
||||||
@@ -197,13 +206,23 @@ accounting and a clear fan-out policy.
|
|||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
1. Validate session id and authorize event access.
|
1. Validate session id and authorize event access.
|
||||||
2. Attach a stream cursor to the session event channel.
|
2. Attach the single active subscriber lease for the session.
|
||||||
3. Send events in worker sequence order.
|
3. Read worker events into a bounded public stream queue.
|
||||||
4. Stop on client cancellation, session close, or session fault.
|
4. Send events in worker sequence order.
|
||||||
5. Emit a terminal status when the session faults if gRPC status alone cannot
|
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.
|
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
|
## Web Dashboard
|
||||||
|
|
||||||
@@ -330,6 +349,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
|
shadow state for diagnostics, but it must not invent, rewrite, or recycle
|
||||||
MXAccess handles.
|
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
|
## Worker Launch
|
||||||
|
|
||||||
The gateway should launch the worker using explicit configuration:
|
The gateway should launch the worker using explicit configuration:
|
||||||
@@ -411,7 +444,7 @@ session ids as protocol faults and close the session.
|
|||||||
|
|
||||||
`WorkerClient` is the gateway-side object that owns one worker connection.
|
`WorkerClient` is the gateway-side object that owns one worker connection.
|
||||||
|
|
||||||
Suggested public shape:
|
Current public shape:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public interface IWorkerClient : IAsyncDisposable
|
public interface IWorkerClient : IAsyncDisposable
|
||||||
@@ -419,6 +452,7 @@ public interface IWorkerClient : IAsyncDisposable
|
|||||||
string SessionId { get; }
|
string SessionId { get; }
|
||||||
int? ProcessId { get; }
|
int? ProcessId { get; }
|
||||||
WorkerClientState State { get; }
|
WorkerClientState State { get; }
|
||||||
|
DateTimeOffset LastHeartbeatAt { get; }
|
||||||
|
|
||||||
Task StartAsync(CancellationToken cancellationToken);
|
Task StartAsync(CancellationToken cancellationToken);
|
||||||
Task<WorkerCommandReply> InvokeAsync(
|
Task<WorkerCommandReply> InvokeAsync(
|
||||||
@@ -438,12 +472,17 @@ Internally it owns:
|
|||||||
- pipe stream,
|
- pipe stream,
|
||||||
- read loop,
|
- read loop,
|
||||||
- write loop,
|
- write loop,
|
||||||
- bounded outbound command/control channel,
|
- outbound command/control channel serialized by the write loop,
|
||||||
- bounded inbound event channel,
|
- bounded inbound event channel,
|
||||||
- pending command dictionary keyed by correlation id,
|
- pending command dictionary keyed by correlation id,
|
||||||
- heartbeat monitor,
|
- heartbeat monitor,
|
||||||
- terminal fault source.
|
- 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
|
### Read Loop
|
||||||
|
|
||||||
The read loop:
|
The read loop:
|
||||||
@@ -555,7 +594,8 @@ worker MXAccess event
|
|||||||
-> worker outbound event queue
|
-> worker outbound event queue
|
||||||
-> worker pipe writer
|
-> worker pipe writer
|
||||||
-> gateway read loop
|
-> gateway read loop
|
||||||
-> session event channel
|
-> worker client event queue
|
||||||
|
-> EventStreamService bounded stream queue
|
||||||
-> gRPC StreamEvents
|
-> gRPC StreamEvents
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -569,13 +609,15 @@ The gateway should record:
|
|||||||
|
|
||||||
Default backpressure policy for parity testing should be fail-fast:
|
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.
|
2. Preserve the overflow details in logs and metrics.
|
||||||
3. Do not silently drop data-change events.
|
3. Do not silently drop data-change events.
|
||||||
|
|
||||||
Do not set a production event-rate target before measurement. Emit event rate,
|
Do not set a production event-rate target before measurement. `GatewayMetrics`
|
||||||
queue depth, stream send latency, and overflow metrics. Later production modes
|
records received event counts by family, queue depth, stream disconnects, and
|
||||||
may support explicit coalescing by item handle as an opt-in behavior.
|
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,
|
The gateway should not synthesize `OperationComplete` from write completion,
|
||||||
command replies, ASB completion queues, or completion-only status frames. Forward
|
command replies, ASB completion queues, or completion-only status frames. Forward
|
||||||
@@ -612,6 +654,25 @@ hashes the presented secret, and compares the stored and presented hashes with
|
|||||||
results distinguish malformed credentials, missing keys, revoked keys, missing
|
results distinguish malformed credentials, missing keys, revoked keys, missing
|
||||||
pepper configuration, and hash mismatch for internal authorization handling.
|
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:
|
Recommended scopes:
|
||||||
|
|
||||||
- `session:open`
|
- `session:open`
|
||||||
@@ -677,6 +738,20 @@ Commands requiring authorization:
|
|||||||
- worker shutdown diagnostics,
|
- worker shutdown diagnostics,
|
||||||
- metadata queries if they expose sensitive plant structure.
|
- 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
|
### Worker IPC
|
||||||
|
|
||||||
Named pipes should be local only. Pipe ACLs should restrict access to:
|
Named pipes should be local only. Pipe ACLs should restrict access to:
|
||||||
@@ -816,9 +891,17 @@ behavior unless an explicit non-parity backend is designed.
|
|||||||
Gateway tests should be able to run without installed MXAccess by using fake
|
Gateway tests should be able to run without installed MXAccess by using fake
|
||||||
workers and fake transports.
|
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:
|
Focused tests:
|
||||||
|
|
||||||
- session state transitions,
|
- 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,
|
- worker startup failures,
|
||||||
- protocol version mismatch,
|
- protocol version mismatch,
|
||||||
- malformed frame handling,
|
- malformed frame handling,
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ Tests:
|
|||||||
|
|
||||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||||
|
|
||||||
|
Status: implemented.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- `Register`,
|
- `Register`,
|
||||||
@@ -216,6 +218,8 @@ Live tests:
|
|||||||
|
|
||||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||||
|
|
||||||
|
Status: implemented.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- `AddItem`,
|
- `AddItem`,
|
||||||
@@ -273,6 +277,8 @@ Live tests:
|
|||||||
|
|
||||||
Labels: `area:worker`, `type:feature`, `priority:p0`
|
Labels: `area:worker`, `type:feature`, `priority:p0`
|
||||||
|
|
||||||
|
Status: implemented.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
- handlers for `OnDataChange`,
|
- handlers for `OnDataChange`,
|
||||||
@@ -447,4 +453,3 @@ Acceptance criteria:
|
|||||||
|
|
||||||
- each public method has planned parity fixture or documented gap,
|
- each public method has planned parity fixture or documented gap,
|
||||||
- gateway results preserve HRESULT/status/value/event shape.
|
- gateway results preserve HRESULT/status/value/event shape.
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,17 @@ The loop should update a heartbeat timestamp after:
|
|||||||
- finishing a command,
|
- finishing a command,
|
||||||
- processing an MXAccess event.
|
- 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
|
## COM Creation
|
||||||
|
|
||||||
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
|
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
|
||||||
@@ -278,6 +289,16 @@ The worker should reference the interop assembly and instantiate
|
|||||||
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
|
`LMXProxyServerClass` on the dedicated STA thread. Keep the ProgID and assembly
|
||||||
path configurable for diagnostics, but this COM class is the v1 default.
|
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:
|
Creation rules:
|
||||||
|
|
||||||
- Create COM object only on the STA.
|
- Create COM object only on the STA.
|
||||||
@@ -295,6 +316,11 @@ If COM creation fails, the worker should send a structured fault with:
|
|||||||
- worker process id,
|
- worker process id,
|
||||||
- session 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.
|
||||||
|
|
||||||
## Event Sink
|
## Event Sink
|
||||||
|
|
||||||
The worker must subscribe to every public MXAccess event family:
|
The worker must subscribe to every public MXAccess event family:
|
||||||
@@ -322,9 +348,28 @@ Event handling rules:
|
|||||||
- Enqueue to the outbound event queue.
|
- Enqueue to the outbound event queue.
|
||||||
- Return quickly to preserve message pumping.
|
- Return quickly to preserve message pumping.
|
||||||
|
|
||||||
If event conversion throws, catch it inside the event handler, enqueue a
|
`MxAccessBaseEventSink` implements the COM connection-point handlers and keeps
|
||||||
structured `WorkerFault` or diagnostic event, and keep the worker alive only if
|
the handlers limited to event argument conversion plus enqueue. It uses
|
||||||
the fault policy allows it.
|
`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
|
## Command Queue
|
||||||
|
|
||||||
@@ -391,6 +436,60 @@ Diagnostics:
|
|||||||
Implement method-specific dispatch instead of a generic string method invoker.
|
Implement method-specific dispatch instead of a generic string method invoker.
|
||||||
Parity tests need stable command-specific request and reply shapes.
|
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
|
## Handle Registry
|
||||||
|
|
||||||
The worker should track MXAccess state for diagnostics and cleanup, while still
|
The worker should track MXAccess state for diagnostics and cleanup, while still
|
||||||
@@ -411,6 +510,13 @@ Rules:
|
|||||||
|
|
||||||
- Do not invent handles.
|
- Do not invent handles.
|
||||||
- Do not rewrite handles returned by MXAccess.
|
- 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 invalid-handle behavior from MXAccess.
|
||||||
- Preserve cross-server handle behavior from MXAccess.
|
- Preserve cross-server handle behavior from MXAccess.
|
||||||
- Use registry state for cleanup and diagnostics, not semantic correction.
|
- Use registry state for cleanup and diagnostics, not semantic correction.
|
||||||
@@ -654,6 +760,10 @@ Live MXAccess tests:
|
|||||||
|
|
||||||
Live tests should be opt-in and clearly marked because they depend on installed
|
Live tests should be opt-in and clearly marked because they depend on installed
|
||||||
MXAccess COM and provider state.
|
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
|
## Initial Implementation Slice
|
||||||
|
|
||||||
|
|||||||
+50
-11
@@ -107,6 +107,24 @@ worker, correlation, command, and client identity fields with redaction applied
|
|||||||
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
|
before values enter log state. `GatewayMetrics` exposes counters, gauges, and
|
||||||
histograms through .NET `Meter` and a snapshot API that dashboard services can
|
histograms through .NET `Meter` and a snapshot API that dashboard services can
|
||||||
project without binding to a metrics exporter.
|
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
|
### Worker Process
|
||||||
|
|
||||||
@@ -518,11 +536,7 @@ Worker policy:
|
|||||||
|
|
||||||
- bounded outbound event channel,
|
- bounded outbound event channel,
|
||||||
- never block MXAccess event handler on pipe writes,
|
- never block MXAccess event handler on pipe writes,
|
||||||
- if the outbound channel is full, apply configured policy:
|
- fail the worker session when the outbound channel is full.
|
||||||
- disconnect session,
|
|
||||||
- drop oldest low-priority data-change events,
|
|
||||||
- coalesce data changes by item handle,
|
|
||||||
- or block briefly then fault.
|
|
||||||
|
|
||||||
For full parity testing, default should be fail-fast rather than silent drop.
|
For full parity testing, default should be fail-fast rather than silent drop.
|
||||||
For production high-rate telemetry, add explicit coalescing modes.
|
For production high-rate telemetry, add explicit coalescing modes.
|
||||||
@@ -531,9 +545,15 @@ Gateway policy:
|
|||||||
|
|
||||||
- one event sequencer per session,
|
- one event sequencer per session,
|
||||||
- preserve per-session event order,
|
- preserve per-session event order,
|
||||||
- support multiple client event subscribers only if explicitly required,
|
- allow one active client event subscriber per session,
|
||||||
- apply backpressure from slow gRPC streams,
|
- reject a second subscriber with a clear session error,
|
||||||
- disconnect or coalesce according to client-selected mode.
|
- 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
|
## Isolation And Fault Handling
|
||||||
|
|
||||||
@@ -566,9 +586,13 @@ Because each client owns one worker, a crash or leak affects only that session.
|
|||||||
External gateway:
|
External gateway:
|
||||||
|
|
||||||
- use TLS for remote gRPC if crossing machine boundaries,
|
- use TLS for remote gRPC if crossing machine boundaries,
|
||||||
- authenticate clients with Windows auth, mTLS, or a deployment-specific token,
|
- authenticate v1 gRPC clients with `authorization: Bearer
|
||||||
- authorize access to commands that can write, authenticate users, or alter
|
mxgw_<key-id>_<secret>` API-key metadata,
|
||||||
runtime state.
|
- 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:
|
Internal worker IPC:
|
||||||
|
|
||||||
@@ -795,6 +819,12 @@ Core operations:
|
|||||||
- track worker state,
|
- track worker state,
|
||||||
- close or kill worker.
|
- 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:
|
State machine:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -842,6 +872,15 @@ The gRPC layer should be thin:
|
|||||||
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
|
Avoid embedding MXAccess-specific business logic in gRPC handlers. Keep the
|
||||||
translation code testable.
|
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
|
## C# Worker Versus C++ Worker
|
||||||
|
|
||||||
Start with a C# .NET Framework 4.8 x86 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ namespace MxGateway.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class GatewayContractInfo
|
public static class GatewayContractInfo
|
||||||
{
|
{
|
||||||
|
public const uint GatewayProtocolVersion = 1;
|
||||||
|
|
||||||
public const uint WorkerProtocolVersion = 1;
|
public const uint WorkerProtocolVersion = 1;
|
||||||
|
|
||||||
public const string DefaultBackendName = "mxaccess-worker";
|
public const string DefaultBackendName = "mxaccess-worker";
|
||||||
|
|||||||
@@ -30,282 +30,282 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
"ChFyZXF1ZXN0ZWRfYmFja2VuZBgBIAEoCRIbChNjbGllbnRfc2Vzc2lvbl9u",
|
"ChFyZXF1ZXN0ZWRfYmFja2VuZBgBIAEoCRIbChNjbGllbnRfc2Vzc2lvbl9u",
|
||||||
"YW1lGAIgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgDIAEoCRIyCg9j",
|
"YW1lGAIgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9pZBgDIAEoCRIyCg9j",
|
||||||
"b21tYW5kX3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRp",
|
"b21tYW5kX3RpbWVvdXQYBCABKAsyGS5nb29nbGUucHJvdG9idWYuRHVyYXRp",
|
||||||
"b24iiAIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
|
"b24iqgIKEE9wZW5TZXNzaW9uUmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRIU",
|
||||||
"CgxiYWNrZW5kX25hbWUYAiABKAkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYAyAB",
|
"CgxiYWNrZW5kX25hbWUYAiABKAkSGQoRd29ya2VyX3Byb2Nlc3NfaWQYAyAB",
|
||||||
"KAUSHwoXd29ya2VyX3Byb3RvY29sX3ZlcnNpb24YBCABKA0SFAoMY2FwYWJp",
|
"KAUSHwoXd29ya2VyX3Byb3RvY29sX3ZlcnNpb24YBCABKA0SFAoMY2FwYWJp",
|
||||||
"bGl0aWVzGAUgAygJEjoKF2RlZmF1bHRfY29tbWFuZF90aW1lb3V0GAYgASgL",
|
"bGl0aWVzGAUgAygJEjoKF2RlZmF1bHRfY29tbWFuZF90aW1lb3V0GAYgASgL",
|
||||||
"MhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjwKD3Byb3RvY29sX3N0YXR1",
|
"MhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uEjwKD3Byb3RvY29sX3N0YXR1",
|
||||||
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMi",
|
"cxgHIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xTdGF0dXMS",
|
||||||
"SAoTQ2xvc2VTZXNzaW9uUmVxdWVzdBISCgpzZXNzaW9uX2lkGAEgASgJEh0K",
|
"IAoYZ2F0ZXdheV9wcm90b2NvbF92ZXJzaW9uGAggASgNIkgKE0Nsb3NlU2Vz",
|
||||||
"FWNsaWVudF9jb3JyZWxhdGlvbl9pZBgCIAEoCSKdAQoRQ2xvc2VTZXNzaW9u",
|
"c2lvblJlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVjbGllbnRfY29y",
|
||||||
"UmVwbHkSEgoKc2Vzc2lvbl9pZBgBIAEoCRI2CgtmaW5hbF9zdGF0ZRgCIAEo",
|
"cmVsYXRpb25faWQYAiABKAkinQEKEUNsb3NlU2Vzc2lvblJlcGx5EhIKCnNl",
|
||||||
"DjIhLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlEjwKD3Byb3Rv",
|
"c3Npb25faWQYASABKAkSNgoLZmluYWxfc3RhdGUYAiABKA4yIS5teGFjY2Vz",
|
||||||
"Y29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9j",
|
"c19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZRI8Cg9wcm90b2NvbF9zdGF0dXMY",
|
||||||
"b2xTdGF0dXMiSAoTU3RyZWFtRXZlbnRzUmVxdWVzdBISCgpzZXNzaW9uX2lk",
|
"AyABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzIkgK",
|
||||||
"GAEgASgJEh0KFWFmdGVyX3dvcmtlcl9zZXF1ZW5jZRgCIAEoBCJ2ChBNeENv",
|
"E1N0cmVhbUV2ZW50c1JlcXVlc3QSEgoKc2Vzc2lvbl9pZBgBIAEoCRIdChVh",
|
||||||
"bW1hbmRSZXF1ZXN0EhIKCnNlc3Npb25faWQYASABKAkSHQoVY2xpZW50X2Nv",
|
"ZnRlcl93b3JrZXJfc2VxdWVuY2UYAiABKAQidgoQTXhDb21tYW5kUmVxdWVz",
|
||||||
"cnJlbGF0aW9uX2lkGAIgASgJEi8KB2NvbW1hbmQYAyABKAsyHi5teGFjY2Vz",
|
"dBISCgpzZXNzaW9uX2lkGAEgASgJEh0KFWNsaWVudF9jb3JyZWxhdGlvbl9p",
|
||||||
"c19nYXRld2F5LnYxLk14Q29tbWFuZCKiDAoJTXhDb21tYW5kEjAKBGtpbmQY",
|
"ZBgCIAEoCRIvCgdjb21tYW5kGAMgASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||||
"ASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZEtpbmQSOAoI",
|
"MS5NeENvbW1hbmQiogwKCU14Q29tbWFuZBIwCgRraW5kGAEgASgOMiIubXhh",
|
||||||
"cmVnaXN0ZXIYCiABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlJlZ2lzdGVy",
|
"Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRLaW5kEjgKCHJlZ2lzdGVyGAog",
|
||||||
"Q29tbWFuZEgAEjwKCnVucmVnaXN0ZXIYCyABKAsyJi5teGFjY2Vzc19nYXRl",
|
"ASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlckNvbW1hbmRIABI8",
|
||||||
"d2F5LnYxLlVucmVnaXN0ZXJDb21tYW5kSAASNwoIYWRkX2l0ZW0YDCABKAsy",
|
"Cgp1bnJlZ2lzdGVyGAsgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5VbnJl",
|
||||||
"Iy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1Db21tYW5kSAASOQoJYWRk",
|
"Z2lzdGVyQ29tbWFuZEgAEjcKCGFkZF9pdGVtGAwgASgLMiMubXhhY2Nlc3Nf",
|
||||||
"X2l0ZW0yGA0gASgLMiQubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtMkNv",
|
"Z2F0ZXdheS52MS5BZGRJdGVtQ29tbWFuZEgAEjkKCWFkZF9pdGVtMhgNIAEo",
|
||||||
"bW1hbmRIABI9CgtyZW1vdmVfaXRlbRgOIAEoCzImLm14YWNjZXNzX2dhdGV3",
|
"CzIkLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRlbTJDb21tYW5kSAASPQoL",
|
||||||
"YXkudjEuUmVtb3ZlSXRlbUNvbW1hbmRIABI0CgZhZHZpc2UYDyABKAsyIi5t",
|
"cmVtb3ZlX2l0ZW0YDiABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLlJlbW92",
|
||||||
"eGFjY2Vzc19nYXRld2F5LnYxLkFkdmlzZUNvbW1hbmRIABI5Cgl1bl9hZHZp",
|
"ZUl0ZW1Db21tYW5kSAASNAoGYWR2aXNlGA8gASgLMiIubXhhY2Nlc3NfZ2F0",
|
||||||
"c2UYECABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLlVuQWR2aXNlQ29tbWFu",
|
"ZXdheS52MS5BZHZpc2VDb21tYW5kSAASOQoJdW5fYWR2aXNlGBAgASgLMiQu",
|
||||||
"ZEgAEksKEmFkdmlzZV9zdXBlcnZpc29yeRgRIAEoCzItLm14YWNjZXNzX2dh",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5VbkFkdmlzZUNvbW1hbmRIABJLChJhZHZp",
|
||||||
"dGV3YXkudjEuQWR2aXNlU3VwZXJ2aXNvcnlDb21tYW5kSAASSAoRYWRkX2J1",
|
"c2Vfc3VwZXJ2aXNvcnkYESABKAsyLS5teGFjY2Vzc19nYXRld2F5LnYxLkFk",
|
||||||
"ZmZlcmVkX2l0ZW0YEiABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEJ1",
|
"dmlzZVN1cGVydmlzb3J5Q29tbWFuZEgAEkgKEWFkZF9idWZmZXJlZF9pdGVt",
|
||||||
"ZmZlcmVkSXRlbUNvbW1hbmRIABJdChxzZXRfYnVmZmVyZWRfdXBkYXRlX2lu",
|
"GBIgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1D",
|
||||||
"dGVydmFsGBMgASgLMjUubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXRCdWZmZXJl",
|
"b21tYW5kSAASXQocc2V0X2J1ZmZlcmVkX3VwZGF0ZV9pbnRlcnZhbBgTIAEo",
|
||||||
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZEgAEjYKB3N1c3BlbmQYFCABKAsyIy5t",
|
"CzI1Lm14YWNjZXNzX2dhdGV3YXkudjEuU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
|
||||||
"eGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRDb21tYW5kSAASOAoIYWN0aXZh",
|
"cnZhbENvbW1hbmRIABI2CgdzdXNwZW5kGBQgASgLMiMubXhhY2Nlc3NfZ2F0",
|
||||||
"dGUYFSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2YXRlQ29tbWFu",
|
"ZXdheS52MS5TdXNwZW5kQ29tbWFuZEgAEjgKCGFjdGl2YXRlGBUgASgLMiQu",
|
||||||
"ZEgAEjIKBXdyaXRlGBYgASgLMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmF0ZUNvbW1hbmRIABIyCgV3cml0",
|
||||||
"ZUNvbW1hbmRIABI0CgZ3cml0ZTIYFyABKAsyIi5teGFjY2Vzc19nYXRld2F5",
|
"ZRgWIAEoCzIhLm14YWNjZXNzX2dhdGV3YXkudjEuV3JpdGVDb21tYW5kSAAS",
|
||||||
"LnYxLldyaXRlMkNvbW1hbmRIABJBCg13cml0ZV9zZWN1cmVkGBggASgLMigu",
|
"NAoGd3JpdGUyGBcgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZTJD",
|
||||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWRDb21tYW5kSAASQwoO",
|
"b21tYW5kSAASQQoNd3JpdGVfc2VjdXJlZBgYIAEoCzIoLm14YWNjZXNzX2dh",
|
||||||
"d3JpdGVfc2VjdXJlZDIYGSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLldy",
|
"dGV3YXkudjEuV3JpdGVTZWN1cmVkQ29tbWFuZEgAEkMKDndyaXRlX3NlY3Vy",
|
||||||
"aXRlU2VjdXJlZDJDb21tYW5kSAASSQoRYXV0aGVudGljYXRlX3VzZXIYGiAB",
|
"ZWQyGBkgASgLMikubXhhY2Nlc3NfZ2F0ZXdheS52MS5Xcml0ZVNlY3VyZWQy",
|
||||||
"KAsyLC5teGFjY2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJDb21t",
|
"Q29tbWFuZEgAEkkKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMiwubXhhY2Nl",
|
||||||
"YW5kSAASTQoUYXJjaGVzdHJhX3VzZXJfdG9faWQYGyABKAsyLS5teGFjY2Vz",
|
"c3NfZ2F0ZXdheS52MS5BdXRoZW50aWNhdGVVc2VyQ29tbWFuZEgAEk0KFGFy",
|
||||||
"c19nYXRld2F5LnYxLkFyY2hlc3RyQVVzZXJUb0lkQ29tbWFuZEgAEjAKBHBp",
|
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMi0ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||||
"bmcYZCABKAsyIC5teGFjY2Vzc19nYXRld2F5LnYxLlBpbmdDb21tYW5kSAAS",
|
"MS5BcmNoZXN0ckFVc2VyVG9JZENvbW1hbmRIABIwCgRwaW5nGGQgASgLMiAu",
|
||||||
"SAoRZ2V0X3Nlc3Npb25fc3RhdGUYZSABKAsyKy5teGFjY2Vzc19nYXRld2F5",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5QaW5nQ29tbWFuZEgAEkgKEWdldF9zZXNz",
|
||||||
"LnYxLkdldFNlc3Npb25TdGF0ZUNvbW1hbmRIABJECg9nZXRfd29ya2VyX2lu",
|
"aW9uX3N0YXRlGGUgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRTZXNz",
|
||||||
"Zm8YZiABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLkdldFdvcmtlckluZm9D",
|
"aW9uU3RhdGVDb21tYW5kSAASRAoPZ2V0X3dvcmtlcl9pbmZvGGYgASgLMiku",
|
||||||
"b21tYW5kSAASPwoMZHJhaW5fZXZlbnRzGGcgASgLMicubXhhY2Nlc3NfZ2F0",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5HZXRXb3JrZXJJbmZvQ29tbWFuZEgAEj8K",
|
||||||
"ZXdheS52MS5EcmFpbkV2ZW50c0NvbW1hbmRIABJFCg9zaHV0ZG93bl93b3Jr",
|
"DGRyYWluX2V2ZW50cxhnIAEoCzInLm14YWNjZXNzX2dhdGV3YXkudjEuRHJh",
|
||||||
"ZXIYaCABKAsyKi5teGFjY2Vzc19nYXRld2F5LnYxLlNodXRkb3duV29ya2Vy",
|
"aW5FdmVudHNDb21tYW5kSAASRQoPc2h1dGRvd25fd29ya2VyGGggASgLMiou",
|
||||||
"Q29tbWFuZEgAQgkKB3BheWxvYWQiJgoPUmVnaXN0ZXJDb21tYW5kEhMKC2Ns",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TaHV0ZG93bldvcmtlckNvbW1hbmRIAEIJ",
|
||||||
"aWVudF9uYW1lGAEgASgJIioKEVVucmVnaXN0ZXJDb21tYW5kEhUKDXNlcnZl",
|
"CgdwYXlsb2FkIiYKD1JlZ2lzdGVyQ29tbWFuZBITCgtjbGllbnRfbmFtZRgB",
|
||||||
"cl9oYW5kbGUYASABKAUiQAoOQWRkSXRlbUNvbW1hbmQSFQoNc2VydmVyX2hh",
|
"IAEoCSIqChFVbnJlZ2lzdGVyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEg",
|
||||||
"bmRsZRgBIAEoBRIXCg9pdGVtX2RlZmluaXRpb24YAiABKAkiVwoPQWRkSXRl",
|
"ASgFIkAKDkFkZEl0ZW1Db21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
|
||||||
"bTJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZp",
|
"FwoPaXRlbV9kZWZpbml0aW9uGAIgASgJIlcKD0FkZEl0ZW0yQ29tbWFuZBIV",
|
||||||
"bml0aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSI/ChFSZW1vdmVJ",
|
"Cg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEo",
|
||||||
"dGVtQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFEhMKC2l0ZW1faGFu",
|
"CRIUCgxpdGVtX2NvbnRleHQYAyABKAkiPwoRUmVtb3ZlSXRlbUNvbW1hbmQS",
|
||||||
"ZGxlGAIgASgFIjsKDUFkdmlzZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
|
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI7",
|
||||||
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSI9Cg9VbkFkdmlzZUNvbW1hbmQS",
|
"Cg1BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
|
||||||
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJG",
|
"bV9oYW5kbGUYAiABKAUiPQoPVW5BZHZpc2VDb21tYW5kEhUKDXNlcnZlcl9o",
|
||||||
"ChhBZHZpc2VTdXBlcnZpc29yeUNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgB",
|
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUiRgoYQWR2aXNlU3Vw",
|
||||||
"IAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJeChZBZGRCdWZmZXJlZEl0ZW1D",
|
"ZXJ2aXNvcnlDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRl",
|
||||||
"b21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSFwoPaXRlbV9kZWZpbml0",
|
"bV9oYW5kbGUYAiABKAUiXgoWQWRkQnVmZmVyZWRJdGVtQ29tbWFuZBIVCg1z",
|
||||||
"aW9uGAIgASgJEhQKDGl0ZW1fY29udGV4dBgDIAEoCSJfCiBTZXRCdWZmZXJl",
|
"ZXJ2ZXJfaGFuZGxlGAEgASgFEhcKD2l0ZW1fZGVmaW5pdGlvbhgCIAEoCRIU",
|
||||||
"ZFVwZGF0ZUludGVydmFsQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
|
"CgxpdGVtX2NvbnRleHQYAyABKAkiXwogU2V0QnVmZmVyZWRVcGRhdGVJbnRl",
|
||||||
"EiQKHHVwZGF0ZV9pbnRlcnZhbF9taWxsaXNlY29uZHMYAiABKAUiPAoOU3Vz",
|
"cnZhbENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIkChx1cGRhdGVf",
|
||||||
"cGVuZENvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hh",
|
"aW50ZXJ2YWxfbWlsbGlzZWNvbmRzGAIgASgFIjwKDlN1c3BlbmRDb21tYW5k",
|
||||||
"bmRsZRgCIAEoBSI9Cg9BY3RpdmF0ZUNvbW1hbmQSFQoNc2VydmVyX2hhbmRs",
|
"EhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUi",
|
||||||
"ZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBSJ4CgxXcml0ZUNvbW1hbmQS",
|
"PQoPQWN0aXZhdGVDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoL",
|
||||||
"FQoNc2VydmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIr",
|
"aXRlbV9oYW5kbGUYAiABKAUieAoMV3JpdGVDb21tYW5kEhUKDXNlcnZlcl9o",
|
||||||
"CgV2YWx1ZRgDIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIP",
|
|
||||||
"Cgd1c2VyX2lkGAQgASgFIrABCg1Xcml0ZTJDb21tYW5kEhUKDXNlcnZlcl9o",
|
|
||||||
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSKwoFdmFsdWUYAyAB",
|
"YW5kbGUYASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSKwoFdmFsdWUYAyAB",
|
||||||
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1w",
|
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHdXNlcl9pZBgE",
|
||||||
"X3ZhbHVlGAQgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlEg8K",
|
"IAEoBSKwAQoNV3JpdGUyQ29tbWFuZBIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgF",
|
||||||
"B3VzZXJfaWQYBSABKAUioQEKE1dyaXRlU2VjdXJlZENvbW1hbmQSFQoNc2Vy",
|
"EhMKC2l0ZW1faGFuZGxlGAIgASgFEisKBXZhbHVlGAMgASgLMhwubXhhY2Nl",
|
||||||
"dmVyX2hhbmRsZRgBIAEoBRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJy",
|
"c3NfZ2F0ZXdheS52MS5NeFZhbHVlEjUKD3RpbWVzdGFtcF92YWx1ZRgEIAEo",
|
||||||
"ZW50X3VzZXJfaWQYAyABKAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIr",
|
"CzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIPCgd1c2VyX2lkGAUg",
|
||||||
"CgV2YWx1ZRgFIAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZSLZ",
|
"ASgFIqEBChNXcml0ZVNlY3VyZWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUY",
|
||||||
"AQoUV3JpdGVTZWN1cmVkMkNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEo",
|
"ASABKAUSEwoLaXRlbV9oYW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lk",
|
||||||
"BRITCgtpdGVtX2hhbmRsZRgCIAEoBRIXCg9jdXJyZW50X3VzZXJfaWQYAyAB",
|
"GAMgASgFEhgKEHZlcmlmaWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSAB",
|
||||||
"KAUSGAoQdmVyaWZpZXJfdXNlcl9pZBgEIAEoBRIrCgV2YWx1ZRgFIAEoCzIc",
|
"KAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUi2QEKFFdyaXRlU2Vj",
|
||||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRI1Cg90aW1lc3RhbXBfdmFs",
|
"dXJlZDJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLaXRlbV9o",
|
||||||
"dWUYBiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiYwoXQXV0",
|
"YW5kbGUYAiABKAUSFwoPY3VycmVudF91c2VyX2lkGAMgASgFEhgKEHZlcmlm",
|
||||||
"aGVudGljYXRlVXNlckNvbW1hbmQSFQoNc2VydmVyX2hhbmRsZRgBIAEoBRIT",
|
"aWVyX3VzZXJfaWQYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19n",
|
||||||
"Cgt2ZXJpZnlfdXNlchgCIAEoCRIcChR2ZXJpZnlfdXNlcl9wYXNzd29yZBgD",
|
"YXRld2F5LnYxLk14VmFsdWUSNQoPdGltZXN0YW1wX3ZhbHVlGAYgASgLMhwu",
|
||||||
"IAEoCSJHChhBcmNoZXN0ckFVc2VyVG9JZENvbW1hbmQSFQoNc2VydmVyX2hh",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZhbHVlImMKF0F1dGhlbnRpY2F0ZVVz",
|
||||||
"bmRsZRgBIAEoBRIUCgx1c2VyX2lkX2d1aWQYAiABKAkiHgoLUGluZ0NvbW1h",
|
"ZXJDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUSEwoLdmVyaWZ5X3Vz",
|
||||||
"bmQSDwoHbWVzc2FnZRgBIAEoCSIYChZHZXRTZXNzaW9uU3RhdGVDb21tYW5k",
|
"ZXIYAiABKAkSHAoUdmVyaWZ5X3VzZXJfcGFzc3dvcmQYAyABKAkiRwoYQXJj",
|
||||||
"IhYKFEdldFdvcmtlckluZm9Db21tYW5kIigKEkRyYWluRXZlbnRzQ29tbWFu",
|
"aGVzdHJBVXNlclRvSWRDb21tYW5kEhUKDXNlcnZlcl9oYW5kbGUYASABKAUS",
|
||||||
"ZBISCgptYXhfZXZlbnRzGAEgASgNIkgKFVNodXRkb3duV29ya2VyQ29tbWFu",
|
"FAoMdXNlcl9pZF9ndWlkGAIgASgJIh4KC1BpbmdDb21tYW5kEg8KB21lc3Nh",
|
||||||
"ZBIvCgxncmFjZV9wZXJpb2QYASABKAsyGS5nb29nbGUucHJvdG9idWYuRHVy",
|
"Z2UYASABKAkiGAoWR2V0U2Vzc2lvblN0YXRlQ29tbWFuZCIWChRHZXRXb3Jr",
|
||||||
"YXRpb24ikAgKDk14Q29tbWFuZFJlcGx5EhIKCnNlc3Npb25faWQYASABKAkS",
|
"ZXJJbmZvQ29tbWFuZCIoChJEcmFpbkV2ZW50c0NvbW1hbmQSEgoKbWF4X2V2",
|
||||||
"FgoOY29ycmVsYXRpb25faWQYAiABKAkSMAoEa2luZBgDIAEoDjIiLm14YWNj",
|
"ZW50cxgBIAEoDSJIChVTaHV0ZG93bldvcmtlckNvbW1hbmQSLwoMZ3JhY2Vf",
|
||||||
"ZXNzX2dhdGV3YXkudjEuTXhDb21tYW5kS2luZBI8Cg9wcm90b2NvbF9zdGF0",
|
"cGVyaW9kGAEgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uIpAICg5N",
|
||||||
"dXMYBCABKAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVz",
|
"eENvbW1hbmRSZXBseRISCgpzZXNzaW9uX2lkGAEgASgJEhYKDmNvcnJlbGF0",
|
||||||
"EhQKB2hyZXN1bHQYBSABKAVIAYgBARIyCgxyZXR1cm5fdmFsdWUYBiABKAsy",
|
"aW9uX2lkGAIgASgJEjAKBGtpbmQYAyABKA4yIi5teGFjY2Vzc19nYXRld2F5",
|
||||||
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSNAoIc3RhdHVzZXMYByAD",
|
"LnYxLk14Q29tbWFuZEtpbmQSPAoPcHJvdG9jb2xfc3RhdHVzGAQgASgLMiMu",
|
||||||
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSGgoSZGlh",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1cxIUCgdocmVzdWx0",
|
||||||
"Z25vc3RpY19tZXNzYWdlGAggASgJEjYKCHJlZ2lzdGVyGBQgASgLMiIubXhh",
|
"GAUgASgFSAGIAQESMgoMcmV0dXJuX3ZhbHVlGAYgASgLMhwubXhhY2Nlc3Nf",
|
||||||
"Y2Nlc3NfZ2F0ZXdheS52MS5SZWdpc3RlclJlcGx5SAASNQoIYWRkX2l0ZW0Y",
|
"Z2F0ZXdheS52MS5NeFZhbHVlEjQKCHN0YXR1c2VzGAcgAygLMiIubXhhY2Nl",
|
||||||
"FSABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW1SZXBseUgAEjcK",
|
"c3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0aWNfbWVz",
|
||||||
"CWFkZF9pdGVtMhgWIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWRkSXRl",
|
"c2FnZRgIIAEoCRI2CghyZWdpc3RlchgUIAEoCzIiLm14YWNjZXNzX2dhdGV3",
|
||||||
"bTJSZXBseUgAEkYKEWFkZF9idWZmZXJlZF9pdGVtGBcgASgLMikubXhhY2Nl",
|
"YXkudjEuUmVnaXN0ZXJSZXBseUgAEjUKCGFkZF9pdGVtGBUgASgLMiEubXhh",
|
||||||
"c3NfZ2F0ZXdheS52MS5BZGRCdWZmZXJlZEl0ZW1SZXBseUgAEjQKB3N1c3Bl",
|
"Y2Nlc3NfZ2F0ZXdheS52MS5BZGRJdGVtUmVwbHlIABI3CglhZGRfaXRlbTIY",
|
||||||
"bmQYGCABKAsyIS5teGFjY2Vzc19nYXRld2F5LnYxLlN1c3BlbmRSZXBseUgA",
|
"FiABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLkFkZEl0ZW0yUmVwbHlIABJG",
|
||||||
"EjYKCGFjdGl2YXRlGBkgASgLMiIubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3Rp",
|
"ChFhZGRfYnVmZmVyZWRfaXRlbRgXIAEoCzIpLm14YWNjZXNzX2dhdGV3YXku",
|
||||||
"dmF0ZVJlcGx5SAASRwoRYXV0aGVudGljYXRlX3VzZXIYGiABKAsyKi5teGFj",
|
"djEuQWRkQnVmZmVyZWRJdGVtUmVwbHlIABI0CgdzdXNwZW5kGBggASgLMiEu",
|
||||||
"Y2Vzc19nYXRld2F5LnYxLkF1dGhlbnRpY2F0ZVVzZXJSZXBseUgAEksKFGFy",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5TdXNwZW5kUmVwbHlIABI2CghhY3RpdmF0",
|
||||||
"Y2hlc3RyYV91c2VyX3RvX2lkGBsgASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52",
|
"ZRgZIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuQWN0aXZhdGVSZXBseUgA",
|
||||||
"MS5BcmNoZXN0ckFVc2VyVG9JZFJlcGx5SAASPwoNc2Vzc2lvbl9zdGF0ZRhk",
|
"EkcKEWF1dGhlbnRpY2F0ZV91c2VyGBogASgLMioubXhhY2Nlc3NfZ2F0ZXdh",
|
||||||
"IAEoCzImLm14YWNjZXNzX2dhdGV3YXkudjEuU2Vzc2lvblN0YXRlUmVwbHlI",
|
"eS52MS5BdXRoZW50aWNhdGVVc2VyUmVwbHlIABJLChRhcmNoZXN0cmFfdXNl",
|
||||||
"ABI7Cgt3b3JrZXJfaW5mbxhlIAEoCzIkLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
"cl90b19pZBgbIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuQXJjaGVzdHJB",
|
||||||
"V29ya2VySW5mb1JlcGx5SAASPQoMZHJhaW5fZXZlbnRzGGYgASgLMiUubXhh",
|
"VXNlclRvSWRSZXBseUgAEj8KDXNlc3Npb25fc3RhdGUYZCABKAsyJi5teGFj",
|
||||||
"Y2Nlc3NfZ2F0ZXdheS52MS5EcmFpbkV2ZW50c1JlcGx5SABCCQoHcGF5bG9h",
|
"Y2Vzc19nYXRld2F5LnYxLlNlc3Npb25TdGF0ZVJlcGx5SAASOwoLd29ya2Vy",
|
||||||
"ZEIKCghfaHJlc3VsdCImCg1SZWdpc3RlclJlcGx5EhUKDXNlcnZlcl9oYW5k",
|
"X2luZm8YZSABKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLldvcmtlckluZm9S",
|
||||||
"bGUYASABKAUiIwoMQWRkSXRlbVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgF",
|
"ZXBseUgAEj0KDGRyYWluX2V2ZW50cxhmIAEoCzIlLm14YWNjZXNzX2dhdGV3",
|
||||||
"IiQKDUFkZEl0ZW0yUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiKwoUQWRk",
|
"YXkudjEuRHJhaW5FdmVudHNSZXBseUgAQgkKB3BheWxvYWRCCgoIX2hyZXN1",
|
||||||
"QnVmZmVyZWRJdGVtUmVwbHkSEwoLaXRlbV9oYW5kbGUYASABKAUiQgoMU3Vz",
|
"bHQiJgoNUmVnaXN0ZXJSZXBseRIVCg1zZXJ2ZXJfaGFuZGxlGAEgASgFIiMK",
|
||||||
"cGVuZFJlcGx5EjIKBnN0YXR1cxgBIAEoCzIiLm14YWNjZXNzX2dhdGV3YXku",
|
"DEFkZEl0ZW1SZXBseRITCgtpdGVtX2hhbmRsZRgBIAEoBSIkCg1BZGRJdGVt",
|
||||||
"djEuTXhTdGF0dXNQcm94eSJDCg1BY3RpdmF0ZVJlcGx5EjIKBnN0YXR1cxgB",
|
"MlJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIisKFEFkZEJ1ZmZlcmVkSXRl",
|
||||||
"IAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eSIoChVB",
|
"bVJlcGx5EhMKC2l0ZW1faGFuZGxlGAEgASgFIkIKDFN1c3BlbmRSZXBseRIy",
|
||||||
"dXRoZW50aWNhdGVVc2VyUmVwbHkSDwoHdXNlcl9pZBgBIAEoBSIpChZBcmNo",
|
"CgZzdGF0dXMYASABKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVz",
|
||||||
"ZXN0ckFVc2VyVG9JZFJlcGx5Eg8KB3VzZXJfaWQYASABKAUiRQoRU2Vzc2lv",
|
"UHJveHkiQwoNQWN0aXZhdGVSZXBseRIyCgZzdGF0dXMYASABKAsyIi5teGFj",
|
||||||
"blN0YXRlUmVwbHkSMAoFc3RhdGUYASABKA4yIS5teGFjY2Vzc19nYXRld2F5",
|
"Y2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkiKAoVQXV0aGVudGljYXRl",
|
||||||
"LnYxLlNlc3Npb25TdGF0ZSJ1Cg9Xb3JrZXJJbmZvUmVwbHkSGQoRd29ya2Vy",
|
"VXNlclJlcGx5Eg8KB3VzZXJfaWQYASABKAUiKQoWQXJjaGVzdHJBVXNlclRv",
|
||||||
"X3Byb2Nlc3NfaWQYASABKAUSFgoOd29ya2VyX3ZlcnNpb24YAiABKAkSFwoP",
|
"SWRSZXBseRIPCgd1c2VyX2lkGAEgASgFIkUKEVNlc3Npb25TdGF0ZVJlcGx5",
|
||||||
"bXhhY2Nlc3NfcHJvZ2lkGAMgASgJEhYKDm14YWNjZXNzX2Nsc2lkGAQgASgJ",
|
"EjAKBXN0YXRlGAEgASgOMiEubXhhY2Nlc3NfZ2F0ZXdheS52MS5TZXNzaW9u",
|
||||||
"IkAKEERyYWluRXZlbnRzUmVwbHkSLAoGZXZlbnRzGAEgAygLMhwubXhhY2Nl",
|
"U3RhdGUidQoPV29ya2VySW5mb1JlcGx5EhkKEXdvcmtlcl9wcm9jZXNzX2lk",
|
||||||
"c3NfZ2F0ZXdheS52MS5NeEV2ZW50IpsGCgdNeEV2ZW50EjIKBmZhbWlseRgB",
|
"GAEgASgFEhYKDndvcmtlcl92ZXJzaW9uGAIgASgJEhcKD214YWNjZXNzX3By",
|
||||||
"IAEoDjIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudEZhbWlseRISCgpz",
|
"b2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNpZBgEIAEoCSJAChBEcmFpbkV2",
|
||||||
"ZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZlcl9oYW5kbGUYAyABKAUSEwoLaXRl",
|
"ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIcLm14YWNjZXNzX2dhdGV3YXku",
|
||||||
"bV9oYW5kbGUYBCABKAUSKwoFdmFsdWUYBSABKAsyHC5teGFjY2Vzc19nYXRl",
|
"djEuTXhFdmVudCKbBgoHTXhFdmVudBIyCgZmYW1pbHkYASABKA4yIi5teGFj",
|
||||||
"d2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0eRgGIAEoBRI0ChBzb3VyY2VfdGlt",
|
"Y2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkSEgoKc2Vzc2lvbl9pZBgC",
|
||||||
"ZXN0YW1wGAcgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBI0Cghz",
|
"IAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMKC2l0ZW1faGFuZGxlGAQg",
|
||||||
"dGF0dXNlcxgIIAMoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
|
"ASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFZh",
|
||||||
"cm94eRIXCg93b3JrZXJfc2VxdWVuY2UYCSABKAQSNAoQd29ya2VyX3RpbWVz",
|
"bHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNlX3RpbWVzdGFtcBgHIAEo",
|
||||||
"dGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASPQoZZ2F0",
|
"CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoIc3RhdHVzZXMYCCAD",
|
||||||
"ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgLIAEoCzIaLmdvb2dsZS5wcm90b2J1",
|
"KAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3RhdHVzUHJveHkSFwoPd29y",
|
||||||
"Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgMIAEoBUgBiAEBEhIKCnJhd19zdGF0",
|
"a2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90aW1lc3RhbXAYCiABKAsy",
|
||||||
"dXMYDSABKAkSQAoOb25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19n",
|
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0KGWdhdGV3YXlfcmVjZWl2",
|
||||||
"YXRld2F5LnYxLk9uRGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29t",
|
"ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
|
||||||
"cGxldGUYFSABKAsyKS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21w",
|
"EhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdfc3RhdHVzGA0gASgJEkAK",
|
||||||
"bGV0ZUV2ZW50SAASSQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhh",
|
"Dm9uX2RhdGFfY2hhbmdlGBQgASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5P",
|
||||||
"Y2Nlc3NfZ2F0ZXdheS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoX",
|
"bkRhdGFDaGFuZ2VFdmVudEgAEkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgL",
|
||||||
"b25fYnVmZmVyZWRfZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRl",
|
"MikubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgA",
|
||||||
"d2F5LnYxLk9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnRIAEIGCgRib2R5QgoK",
|
"EkkKEm9wZXJhdGlvbl9jb21wbGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3",
|
||||||
"CF9ocmVzdWx0IhMKEU9uRGF0YUNoYW5nZUV2ZW50IhYKFE9uV3JpdGVDb21w",
|
"YXkudjEuT3BlcmF0aW9uQ29tcGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVk",
|
||||||
"bGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRlRXZlbnQi1AEKGU9uQnVm",
|
"X2RhdGFfY2hhbmdlGBcgASgLMi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1",
|
||||||
"ZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhh",
|
"ZmZlcmVkRGF0YUNoYW5nZUV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdCIT",
|
||||||
"Y2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQKDnF1YWxpdHlfdmFsdWVz",
|
"ChFPbkRhdGFDaGFuZ2VFdmVudCIWChRPbldyaXRlQ29tcGxldGVFdmVudCIY",
|
||||||
"GAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EjYKEHRpbWVz",
|
"ChZPcGVyYXRpb25Db21wbGV0ZUV2ZW50ItQBChlPbkJ1ZmZlcmVkRGF0YUNo",
|
||||||
"dGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14QXJy",
|
"YW5nZUV2ZW50EjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3",
|
||||||
"YXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLrAQoNTXhTdGF0dXNQcm94eRIP",
|
"YXkudjEuTXhEYXRhVHlwZRI0Cg5xdWFsaXR5X3ZhbHVlcxgCIAEoCzIcLm14",
|
||||||
"CgdzdWNjZXNzGAEgASgFEjcKCGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3Nf",
|
"YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRI2ChB0aW1lc3RhbXBfdmFsdWVz",
|
||||||
"Z2F0ZXdheS52MS5NeFN0YXR1c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMg",
|
"GAMgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeEFycmF5EhUKDXJhd19k",
|
||||||
"ASgOMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZk",
|
"YXRhX3R5cGUYBCABKAUi6wEKDU14U3RhdHVzUHJveHkSDwoHc3VjY2VzcxgB",
|
||||||
"ZXRhaWwYBCABKAUSFAoMcmF3X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRl",
|
"IAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||||
"Y3RlZF9ieRgGIAEoBRIXCg9kaWFnbm9zdGljX3RleHQYByABKAkipwMKB014",
|
"TXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEoDjIjLm14YWNj",
|
||||||
"VmFsdWUSMgoJZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
"ZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0YWlsGAQgASgF",
|
||||||
"MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxs",
|
"EhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0ZWRfYnkYBiAB",
|
||||||
"GAMgASgIEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5",
|
"KAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIqcDCgdNeFZhbHVlEjIKCWRh",
|
||||||
"cGUYBSABKAUSFAoKYm9vbF92YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVl",
|
"dGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEuTXhEYXRhVHlw",
|
||||||
"GAsgASgFSAASFQoLaW50NjRfdmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1",
|
"ZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgDIAEoCBIWCg5y",
|
||||||
"ZRgNIAEoAkgAEhYKDGRvdWJsZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192",
|
"YXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBlGAUgASgFEhQK",
|
||||||
"YWx1ZRgPIAEoCUgAEjUKD3RpbWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2ds",
|
"CmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgLIAEoBUgAEhUK",
|
||||||
"ZS5wcm90b2J1Zi5UaW1lc3RhbXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIc",
|
"C2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUYDSABKAJIABIW",
|
||||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgS",
|
"Cgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFsdWUYDyABKAlI",
|
||||||
"IAEoDEgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5",
|
"ABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUucHJvdG9idWYu",
|
||||||
"cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoM",
|
"VGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5teGFjY2Vzc19n",
|
||||||
"dmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoOcmF3",
|
"YXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiABKAxIAEIGCgRr",
|
||||||
"X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBlGAUg",
|
"aW5kIv4ECgdNeEFycmF5EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8u",
|
||||||
"ASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlw",
|
||||||
"MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFjY2Vz",
|
"ZRgCIAEoCRISCgpkaW1lbnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGlj",
|
||||||
"c19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMYDCAB",
|
"GAQgASgJEh0KFXJhd19lbGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29s",
|
||||||
"KAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3CgxmbG9h",
|
"X3ZhbHVlcxgKIAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5",
|
||||||
"dF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0QXJy",
|
"SAASNwoMaW50MzJfdmFsdWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||||
"YXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0ZXdh",
|
"MS5JbnQzMkFycmF5SAASNwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nl",
|
||||||
"eS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsyIC5t",
|
"c3NfZ2F0ZXdheS52MS5JbnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0g",
|
||||||
"eGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0YW1w",
|
"ASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91",
|
||||||
"X3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0YW1w",
|
"YmxlX3ZhbHVlcxgOIAEoCzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91Ymxl",
|
||||||
"QXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0ZXdh",
|
"QXJyYXlIABI5Cg1zdHJpbmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0",
|
||||||
"eS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyIbCglCb29sQXJyYXkSDgoGdmFs",
|
"ZXdheS52MS5TdHJpbmdBcnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECAB",
|
||||||
"dWVzGAEgAygIIhwKCkludDMyQXJyYXkSDgoGdmFsdWVzGAEgAygFIhwKCklu",
|
"KAsyIy5teGFjY2Vzc19nYXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoK",
|
||||||
"dDY0QXJyYXkSDgoGdmFsdWVzGAEgAygDIhwKCkZsb2F0QXJyYXkSDgoGdmFs",
|
"cmF3X3ZhbHVlcxgRIAEoCzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJy",
|
||||||
"dWVzGAEgAygCIh0KC0RvdWJsZUFycmF5Eg4KBnZhbHVlcxgBIAMoASIdCgtT",
|
"YXlIAEIICgZ2YWx1ZXMiGwoJQm9vbEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIc",
|
||||||
"dHJpbmdBcnJheRIOCgZ2YWx1ZXMYASADKAkiPAoOVGltZXN0YW1wQXJyYXkS",
|
"CgpJbnQzMkFycmF5Eg4KBnZhbHVlcxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4K",
|
||||||
"KgoGdmFsdWVzGAEgAygLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa",
|
"BnZhbHVlcxgBIAMoAyIcCgpGbG9hdEFycmF5Eg4KBnZhbHVlcxgBIAMoAiId",
|
||||||
"CghSYXdBcnJheRIOCgZ2YWx1ZXMYASADKAwiWAoOUHJvdG9jb2xTdGF0dXMS",
|
"CgtEb3VibGVBcnJheRIOCgZ2YWx1ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkS",
|
||||||
"NQoEY29kZRgBIAEoDjInLm14YWNjZXNzX2dhdGV3YXkudjEuUHJvdG9jb2xT",
|
"DgoGdmFsdWVzGAEgAygJIjwKDlRpbWVzdGFtcEFycmF5EioKBnZhbHVlcxgB",
|
||||||
"dGF0dXNDb2RlEg8KB21lc3NhZ2UYAiABKAkqvwYKDU14Q29tbWFuZEtpbmQS",
|
"IAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkS",
|
||||||
"HwobTVhfQ09NTUFORF9LSU5EX1VOU1BFQ0lGSUVEEAASHAoYTVhfQ09NTUFO",
|
"DgoGdmFsdWVzGAEgAygMIlgKDlByb3RvY29sU3RhdHVzEjUKBGNvZGUYASAB",
|
||||||
"RF9LSU5EX1JFR0lTVEVSEAESHgoaTVhfQ09NTUFORF9LSU5EX1VOUkVHSVNU",
|
"KA4yJy5teGFjY2Vzc19nYXRld2F5LnYxLlByb3RvY29sU3RhdHVzQ29kZRIP",
|
||||||
"RVIQAhIcChhNWF9DT01NQU5EX0tJTkRfQUREX0lURU0QAxIdChlNWF9DT01N",
|
"CgdtZXNzYWdlGAIgASgJKr8GCg1NeENvbW1hbmRLaW5kEh8KG01YX0NPTU1B",
|
||||||
"QU5EX0tJTkRfQUREX0lURU0yEAQSHwobTVhfQ09NTUFORF9LSU5EX1JFTU9W",
|
"TkRfS0lORF9VTlNQRUNJRklFRBAAEhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJ",
|
||||||
"RV9JVEVNEAUSGgoWTVhfQ09NTUFORF9LSU5EX0FEVklTRRAGEh0KGU1YX0NP",
|
"U1RFUhABEh4KGk1YX0NPTU1BTkRfS0lORF9VTlJFR0lTVEVSEAISHAoYTVhf",
|
||||||
"TU1BTkRfS0lORF9VTl9BRFZJU0UQBxImCiJNWF9DT01NQU5EX0tJTkRfQURW",
|
"Q09NTUFORF9LSU5EX0FERF9JVEVNEAMSHQoZTVhfQ09NTUFORF9LSU5EX0FE",
|
||||||
"SVNFX1NVUEVSVklTT1JZEAgSJQohTVhfQ09NTUFORF9LSU5EX0FERF9CVUZG",
|
"RF9JVEVNMhAEEh8KG01YX0NPTU1BTkRfS0lORF9SRU1PVkVfSVRFTRAFEhoK",
|
||||||
"RVJFRF9JVEVNEAkSMAosTVhfQ09NTUFORF9LSU5EX1NFVF9CVUZGRVJFRF9V",
|
"Fk1YX0NPTU1BTkRfS0lORF9BRFZJU0UQBhIdChlNWF9DT01NQU5EX0tJTkRf",
|
||||||
"UERBVEVfSU5URVJWQUwQChIbChdNWF9DT01NQU5EX0tJTkRfU1VTUEVORBAL",
|
"VU5fQURWSVNFEAcSJgoiTVhfQ09NTUFORF9LSU5EX0FEVklTRV9TVVBFUlZJ",
|
||||||
"EhwKGE1YX0NPTU1BTkRfS0lORF9BQ1RJVkFURRAMEhkKFU1YX0NPTU1BTkRf",
|
"U09SWRAIEiUKIU1YX0NPTU1BTkRfS0lORF9BRERfQlVGRkVSRURfSVRFTRAJ",
|
||||||
"S0lORF9XUklURRANEhoKFk1YX0NPTU1BTkRfS0lORF9XUklURTIQDhIhCh1N",
|
"EjAKLE1YX0NPTU1BTkRfS0lORF9TRVRfQlVGRkVSRURfVVBEQVRFX0lOVEVS",
|
||||||
"WF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRBAPEiIKHk1YX0NPTU1BTkRf",
|
"VkFMEAoSGwoXTVhfQ09NTUFORF9LSU5EX1NVU1BFTkQQCxIcChhNWF9DT01N",
|
||||||
"S0lORF9XUklURV9TRUNVUkVEMhAQEiUKIU1YX0NPTU1BTkRfS0lORF9BVVRI",
|
"QU5EX0tJTkRfQUNUSVZBVEUQDBIZChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQ",
|
||||||
"RU5USUNBVEVfVVNFUhAREigKJE1YX0NPTU1BTkRfS0lORF9BUkNIRVNUUkFf",
|
"DRIaChZNWF9DT01NQU5EX0tJTkRfV1JJVEUyEA4SIQodTVhfQ09NTUFORF9L",
|
||||||
"VVNFUl9UT19JRBASEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhf",
|
"SU5EX1dSSVRFX1NFQ1VSRUQQDxIiCh5NWF9DT01NQU5EX0tJTkRfV1JJVEVf",
|
||||||
"Q09NTUFORF9LSU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFO",
|
"U0VDVVJFRDIQEBIlCiFNWF9DT01NQU5EX0tJTkRfQVVUSEVOVElDQVRFX1VT",
|
||||||
"RF9LSU5EX0dFVF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9E",
|
"RVIQERIoCiRNWF9DT01NQU5EX0tJTkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQ",
|
||||||
"UkFJTl9FVkVOVFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09S",
|
"EhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBkEiUKIU1YX0NPTU1BTkRfS0lO",
|
||||||
"S0VSEGgq0AEKDU14RXZlbnRGYW1pbHkSHwobTVhfRVZFTlRfRkFNSUxZX1VO",
|
"RF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01YX0NPTU1BTkRfS0lORF9HRVRf",
|
||||||
"U1BFQ0lGSUVEEAASIgoeTVhfRVZFTlRfRkFNSUxZX09OX0RBVEFfQ0hBTkdF",
|
"V09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5EX0tJTkRfRFJBSU5fRVZFTlRT",
|
||||||
"EAESJQohTVhfRVZFTlRfRkFNSUxZX09OX1dSSVRFX0NPTVBMRVRFEAISJgoi",
|
"EGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRET1dOX1dPUktFUhBoKtABCg1N",
|
||||||
"TVhfRVZFTlRfRkFNSUxZX09QRVJBVElPTl9DT01QTEVURRADEisKJ01YX0VW",
|
"eEV2ZW50RmFtaWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAA",
|
||||||
"RU5UX0ZBTUlMWV9PTl9CVUZGRVJFRF9EQVRBX0NIQU5HRRAEKqUDChBNeFN0",
|
"EiIKHk1YX0VWRU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VW",
|
||||||
"YXR1c0NhdGVnb3J5EiIKHk1YX1NUQVRVU19DQVRFR09SWV9VTlNQRUNJRklF",
|
"RU5UX0ZBTUlMWV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZB",
|
||||||
"RBAAEh4KGk1YX1NUQVRVU19DQVRFR09SWV9VTktOT1dOEAESGQoVTVhfU1RB",
|
"TUlMWV9PUEVSQVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlf",
|
||||||
"VFVTX0NBVEVHT1JZX09LEAISHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1BFTkRJ",
|
"T05fQlVGRkVSRURfREFUQV9DSEFOR0UQBCqlAwoQTXhTdGF0dXNDYXRlZ29y",
|
||||||
"TkcQAxIeChpNWF9TVEFUVVNfQ0FURUdPUllfV0FSTklORxAEEioKJk1YX1NU",
|
"eRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJRUQQABIeChpNWF9T",
|
||||||
"QVRVU19DQVRFR09SWV9DT01NVU5JQ0FUSU9OX0VSUk9SEAUSKgomTVhfU1RB",
|
"VEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NUQVRVU19DQVRFR09S",
|
||||||
"VFVTX0NBVEVHT1JZX0NPTkZJR1VSQVRJT05fRVJST1IQBhIoCiRNWF9TVEFU",
|
"WV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5ESU5HEAMSHgoaTVhf",
|
||||||
"VVNfQ0FURUdPUllfT1BFUkFUSU9OQUxfRVJST1IQBxIlCiFNWF9TVEFUVVNf",
|
"U1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9TVEFUVVNfQ0FURUdP",
|
||||||
"Q0FURUdPUllfU0VDVVJJVFlfRVJST1IQCBIlCiFNWF9TVEFUVVNfQ0FURUdP",
|
"UllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NUQVRVU19DQVRFR09S",
|
||||||
"UllfU09GVFdBUkVfRVJST1IQCRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfT1RI",
|
"WV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RBVFVTX0NBVEVHT1JZ",
|
||||||
"RVJfRVJST1IQCirKAgoOTXhTdGF0dXNTb3VyY2USIAocTVhfU1RBVFVTX1NP",
|
"X09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NF",
|
||||||
"VVJDRV9VTlNQRUNJRklFRBAAEhwKGE1YX1NUQVRVU19TT1VSQ0VfVU5LTk9X",
|
"Q1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVHT1JZX1NPRlRXQVJF",
|
||||||
"ThABEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVRVUVTVElOR19MTVgQAhIjCh9N",
|
"X0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09USEVSX0VSUk9SEAoq",
|
||||||
"WF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdfTE1YEAMSIwofTVhfU1RBVFVT",
|
"ygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19TT1VSQ0VfVU5TUEVD",
|
||||||
"X1NPVVJDRV9SRVFVRVNUSU5HX05NWBAEEiMKH01YX1NUQVRVU19TT1VSQ0Vf",
|
"SUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05PV04QARIjCh9NWF9T",
|
||||||
"UkVTUE9ORElOR19OTVgQBRIxCi1NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJ",
|
"VEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwofTVhfU1RBVFVTX1NP",
|
||||||
"TkdfQVVUT01BVElPTl9PQkpFQ1QQBhIxCi1NWF9TVEFUVVNfU09VUkNFX1JF",
|
"VVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVR",
|
||||||
"U1BPTkRJTkdfQVVUT01BVElPTl9PQkpFQ1QQByrdBAoKTXhEYXRhVHlwZRIc",
|
"VUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNFX1JFU1BPTkRJTkdf",
|
||||||
"ChhNWF9EQVRBX1RZUEVfVU5TUEVDSUZJRUQQABIYChRNWF9EQVRBX1RZUEVf",
|
"Tk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNUSU5HX0FVVE9NQVRJ",
|
||||||
"VU5LTk9XThABEhgKFE1YX0RBVEFfVFlQRV9OT19EQVRBEAISGAoUTVhfREFU",
|
"T05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0FV",
|
||||||
"QV9UWVBFX0JPT0xFQU4QAxIYChRNWF9EQVRBX1RZUEVfSU5URUdFUhAEEhYK",
|
"VE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUSHAoYTVhfREFUQV9U",
|
||||||
"Ek1YX0RBVEFfVFlQRV9GTE9BVBAFEhcKE01YX0RBVEFfVFlQRV9ET1VCTEUQ",
|
"WVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBFX1VOS05PV04QARIY",
|
||||||
"BhIXChNNWF9EQVRBX1RZUEVfU1RSSU5HEAcSFQoRTVhfREFUQV9UWVBFX1RJ",
|
"ChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RBVEFfVFlQRV9CT09M",
|
||||||
"TUUQCBIdChlNWF9EQVRBX1RZUEVfRUxBUFNFRF9USU1FEAkSHwobTVhfREFU",
|
"RUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIWChJNWF9EQVRBX1RZ",
|
||||||
"QV9UWVBFX1JFRkVSRU5DRV9UWVBFEAoSHAoYTVhfREFUQV9UWVBFX1NUQVRV",
|
"UEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxFEAYSFwoTTVhfREFU",
|
||||||
"U19UWVBFEAsSFQoRTVhfREFUQV9UWVBFX0VOVU0QDBItCilNWF9EQVRBX1RZ",
|
"QV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9USU1FEAgSHQoZTVhf",
|
||||||
"UEVfU0VDVVJJVFlfQ0xBU1NJRklDQVRJT05fRU5VTRANEiIKHk1YX0RBVEFf",
|
"REFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RBVEFfVFlQRV9SRUZF",
|
||||||
"VFlQRV9EQVRBX1FVQUxJVFlfVFlQRRAOEh8KG01YX0RBVEFfVFlQRV9RVUFM",
|
"UkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFUVVNfVFlQRRALEhUK",
|
||||||
"SUZJRURfRU5VTRAPEiEKHU1YX0RBVEFfVFlQRV9RVUFMSUZJRURfU1RSVUNU",
|
"EU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9UWVBFX1NFQ1VSSVRZ",
|
||||||
"EBASKQolTVhfREFUQV9UWVBFX0lOVEVSTkFUSU9OQUxJWkVEX1NUUklORxAR",
|
"X0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRBX1RZUEVfREFUQV9R",
|
||||||
"EhsKF01YX0RBVEFfVFlQRV9CSUdfU1RSSU5HEBISFAoQTVhfREFUQV9UWVBF",
|
"VUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVBTElGSUVEX0VOVU0Q",
|
||||||
"X0VORBATKqMDChJQcm90b2NvbFN0YXR1c0NvZGUSJAogUFJPVE9DT0xfU1RB",
|
"DxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVDVBAQEikKJU1YX0RB",
|
||||||
"VFVTX0NPREVfVU5TUEVDSUZJRUQQABIbChdQUk9UT0NPTF9TVEFUVVNfQ09E",
|
"VEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQERIbChdNWF9EQVRB",
|
||||||
"RV9PSxABEigKJFBST1RPQ09MX1NUQVRVU19DT0RFX0lOVkFMSURfUkVRVUVT",
|
"X1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQRV9FTkQQEyqjAwoS",
|
||||||
"VBACEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX0ZPVU5E",
|
"UHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NUQVRVU19DT0RFX1VO",
|
||||||
"EAMSKgomUFJPVE9DT0xfU1RBVFVTX0NPREVfU0VTU0lPTl9OT1RfUkVBRFkQ",
|
"U1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NPREVfT0sQARIoCiRQ",
|
||||||
"BBIrCidQUk9UT0NPTF9TVEFUVVNfQ09ERV9XT1JLRVJfVU5BVkFJTEFCTEUQ",
|
"Uk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVFU1QQAhIqCiZQUk9U",
|
||||||
"BRIgChxQUk9UT0NPTF9TVEFUVVNfQ09ERV9USU1FT1VUEAYSIQodUFJPVE9D",
|
"T0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VORBADEioKJlBST1RP",
|
||||||
"T0xfU1RBVFVTX0NPREVfQ0FOQ0VMRUQQBxIrCidQUk9UT0NPTF9TVEFUVVNf",
|
"Q09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZEAQSKwonUFJPVE9D",
|
||||||
"Q09ERV9QUk9UT0NPTF9WSU9MQVRJT04QCBIpCiVQUk9UT0NPTF9TVEFUVVNf",
|
"T0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxFEAUSIAocUFJPVE9D",
|
||||||
"Q09ERV9NWEFDQ0VTU19GQUlMVVJFEAkqvwIKDFNlc3Npb25TdGF0ZRIdChlT",
|
"T0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RPQ09MX1NUQVRVU19D",
|
||||||
"RVNTSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASGgoWU0VTU0lPTl9TVEFURV9D",
|
"T0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfUFJPVE9D",
|
||||||
"UkVBVElORxABEiEKHVNFU1NJT05fU1RBVEVfU1RBUlRJTkdfV09SS0VSEAIS",
|
"T0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVTX0NPREVfTVhBQ0NF",
|
||||||
"IgoeU0VTU0lPTl9TVEFURV9XQUlUSU5HX0ZPUl9QSVBFEAMSHQoZU0VTU0lP",
|
"U1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZU0VTU0lPTl9TVEFU",
|
||||||
"Tl9TVEFURV9IQU5EU0hBS0lORxAEEiUKIVNFU1NJT05fU1RBVEVfSU5JVElB",
|
"RV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVfQ1JFQVRJTkcQARIh",
|
||||||
"TElaSU5HX1dPUktFUhAFEhcKE1NFU1NJT05fU1RBVEVfUkVBRFkQBhIZChVT",
|
"Ch1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhACEiIKHlNFU1NJT05f",
|
||||||
"RVNTSU9OX1NUQVRFX0NMT1NJTkcQBxIYChRTRVNTSU9OX1NUQVRFX0NMT1NF",
|
"U1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJT05fU1RBVEVfSEFO",
|
||||||
"RBAIEhkKFVNFU1NJT05fU1RBVEVfRkFVTFRFRBAJMoIDCg9NeEFjY2Vzc0dh",
|
"RFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJQUxJWklOR19XT1JL",
|
||||||
"dGV3YXkSXQoLT3BlblNlc3Npb24SJy5teGFjY2Vzc19nYXRld2F5LnYxLk9w",
|
"RVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoVU0VTU0lPTl9TVEFU",
|
||||||
"ZW5TZXNzaW9uUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlblNl",
|
"RV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9TRUQQCBIZChVTRVNT",
|
||||||
"c3Npb25SZXBseRJgCgxDbG9zZVNlc3Npb24SKC5teGFjY2Vzc19nYXRld2F5",
|
"SU9OX1NUQVRFX0ZBVUxURUQQCTKCAwoPTXhBY2Nlc3NHYXRld2F5El0KC09w",
|
||||||
"LnYxLkNsb3NlU2Vzc2lvblJlcXVlc3QaJi5teGFjY2Vzc19nYXRld2F5LnYx",
|
"ZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5PcGVuU2Vzc2lvblJl",
|
||||||
"LkNsb3NlU2Vzc2lvblJlcGx5ElQKBkludm9rZRIlLm14YWNjZXNzX2dhdGV3",
|
"cXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5TZXNzaW9uUmVwbHkS",
|
||||||
"YXkudjEuTXhDb21tYW5kUmVxdWVzdBojLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
"YAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNl",
|
||||||
"TXhDb21tYW5kUmVwbHkSWAoMU3RyZWFtRXZlbnRzEigubXhhY2Nlc3NfZ2F0",
|
"c3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5DbG9zZVNlc3Np",
|
||||||
"ZXdheS52MS5TdHJlYW1FdmVudHNSZXF1ZXN0GhwubXhhY2Nlc3NfZ2F0ZXdh",
|
"b25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29t",
|
||||||
"eS52MS5NeEV2ZW50MAFCHKoCGU14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9i",
|
"bWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYxLk14Q29tbWFuZFJl",
|
||||||
"BnByb3RvMw=="));
|
"cGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dhdGV3YXkudjEuU3Ry",
|
||||||
|
"ZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVu",
|
||||||
|
"dDABQhyqAhlNeEdhdGV3YXkuQ29udHJhY3RzLlByb3RvYgZwcm90bzM="));
|
||||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
||||||
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
|
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::MxGateway.Contracts.Proto.MxCommandKind), typeof(global::MxGateway.Contracts.Proto.MxEventFamily), typeof(global::MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::MxGateway.Contracts.Proto.MxStatusSource), typeof(global::MxGateway.Contracts.Proto.MxDataType), typeof(global::MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionRequest), global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionRequest), global::MxGateway.Contracts.Proto.OpenSessionRequest.Parser, new[]{ "RequestedBackend", "ClientSessionName", "ClientCorrelationId", "CommandTimeout" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.OpenSessionReply), global::MxGateway.Contracts.Proto.OpenSessionReply.Parser, new[]{ "SessionId", "BackendName", "WorkerProcessId", "WorkerProtocolVersion", "Capabilities", "DefaultCommandTimeout", "ProtocolStatus", "GatewayProtocolVersion" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionRequest), global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionRequest), global::MxGateway.Contracts.Proto.CloseSessionRequest.Parser, new[]{ "SessionId", "ClientCorrelationId" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionReply), global::MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.CloseSessionReply), global::MxGateway.Contracts.Proto.CloseSessionReply.Parser, new[]{ "SessionId", "FinalState", "ProtocolStatus" }, null, null, null, null),
|
||||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamEventsRequest), global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null),
|
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.StreamEventsRequest), global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser, new[]{ "SessionId", "AfterWorkerSequence" }, null, null, null, null),
|
||||||
@@ -841,6 +841,7 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
capabilities_ = other.capabilities_.Clone();
|
capabilities_ = other.capabilities_.Clone();
|
||||||
defaultCommandTimeout_ = other.defaultCommandTimeout_ != null ? other.defaultCommandTimeout_.Clone() : null;
|
defaultCommandTimeout_ = other.defaultCommandTimeout_ != null ? other.defaultCommandTimeout_.Clone() : null;
|
||||||
protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null;
|
protocolStatus_ = other.protocolStatus_ != null ? other.protocolStatus_.Clone() : null;
|
||||||
|
gatewayProtocolVersion_ = other.gatewayProtocolVersion_;
|
||||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,6 +934,23 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Field number for the "gateway_protocol_version" field.</summary>
|
||||||
|
public const int GatewayProtocolVersionFieldNumber = 8;
|
||||||
|
private uint gatewayProtocolVersion_;
|
||||||
|
/// <summary>
|
||||||
|
/// Public gateway contract version implemented by this endpoint. Clients use
|
||||||
|
/// this value to reject incompatible generated-code inputs before issuing
|
||||||
|
/// command-specific MXAccess calls.
|
||||||
|
/// </summary>
|
||||||
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
|
public uint GatewayProtocolVersion {
|
||||||
|
get { return gatewayProtocolVersion_; }
|
||||||
|
set {
|
||||||
|
gatewayProtocolVersion_ = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||||
public override bool Equals(object other) {
|
public override bool Equals(object other) {
|
||||||
@@ -955,6 +973,7 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
if(!capabilities_.Equals(other.capabilities_)) return false;
|
if(!capabilities_.Equals(other.capabilities_)) return false;
|
||||||
if (!object.Equals(DefaultCommandTimeout, other.DefaultCommandTimeout)) return false;
|
if (!object.Equals(DefaultCommandTimeout, other.DefaultCommandTimeout)) return false;
|
||||||
if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false;
|
if (!object.Equals(ProtocolStatus, other.ProtocolStatus)) return false;
|
||||||
|
if (GatewayProtocolVersion != other.GatewayProtocolVersion) return false;
|
||||||
return Equals(_unknownFields, other._unknownFields);
|
return Equals(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,6 +988,7 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
hash ^= capabilities_.GetHashCode();
|
hash ^= capabilities_.GetHashCode();
|
||||||
if (defaultCommandTimeout_ != null) hash ^= DefaultCommandTimeout.GetHashCode();
|
if (defaultCommandTimeout_ != null) hash ^= DefaultCommandTimeout.GetHashCode();
|
||||||
if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode();
|
if (protocolStatus_ != null) hash ^= ProtocolStatus.GetHashCode();
|
||||||
|
if (GatewayProtocolVersion != 0) hash ^= GatewayProtocolVersion.GetHashCode();
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
hash ^= _unknownFields.GetHashCode();
|
hash ^= _unknownFields.GetHashCode();
|
||||||
}
|
}
|
||||||
@@ -1012,6 +1032,10 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
output.WriteRawTag(58);
|
output.WriteRawTag(58);
|
||||||
output.WriteMessage(ProtocolStatus);
|
output.WriteMessage(ProtocolStatus);
|
||||||
}
|
}
|
||||||
|
if (GatewayProtocolVersion != 0) {
|
||||||
|
output.WriteRawTag(64);
|
||||||
|
output.WriteUInt32(GatewayProtocolVersion);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(output);
|
_unknownFields.WriteTo(output);
|
||||||
}
|
}
|
||||||
@@ -1047,6 +1071,10 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
output.WriteRawTag(58);
|
output.WriteRawTag(58);
|
||||||
output.WriteMessage(ProtocolStatus);
|
output.WriteMessage(ProtocolStatus);
|
||||||
}
|
}
|
||||||
|
if (GatewayProtocolVersion != 0) {
|
||||||
|
output.WriteRawTag(64);
|
||||||
|
output.WriteUInt32(GatewayProtocolVersion);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
_unknownFields.WriteTo(ref output);
|
_unknownFields.WriteTo(ref output);
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1104,9 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
if (protocolStatus_ != null) {
|
if (protocolStatus_ != null) {
|
||||||
size += 1 + pb::CodedOutputStream.ComputeMessageSize(ProtocolStatus);
|
size += 1 + pb::CodedOutputStream.ComputeMessageSize(ProtocolStatus);
|
||||||
}
|
}
|
||||||
|
if (GatewayProtocolVersion != 0) {
|
||||||
|
size += 1 + pb::CodedOutputStream.ComputeUInt32Size(GatewayProtocolVersion);
|
||||||
|
}
|
||||||
if (_unknownFields != null) {
|
if (_unknownFields != null) {
|
||||||
size += _unknownFields.CalculateSize();
|
size += _unknownFields.CalculateSize();
|
||||||
}
|
}
|
||||||
@@ -1113,6 +1144,9 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
}
|
}
|
||||||
ProtocolStatus.MergeFrom(other.ProtocolStatus);
|
ProtocolStatus.MergeFrom(other.ProtocolStatus);
|
||||||
}
|
}
|
||||||
|
if (other.GatewayProtocolVersion != 0) {
|
||||||
|
GatewayProtocolVersion = other.GatewayProtocolVersion;
|
||||||
|
}
|
||||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1166,6 +1200,10 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
input.ReadMessage(ProtocolStatus);
|
input.ReadMessage(ProtocolStatus);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 64: {
|
||||||
|
GatewayProtocolVersion = input.ReadUInt32();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -1219,6 +1257,10 @@ namespace MxGateway.Contracts.Proto {
|
|||||||
input.ReadMessage(ProtocolStatus);
|
input.ReadMessage(ProtocolStatus);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 64: {
|
||||||
|
GatewayProtocolVersion = input.ReadUInt32();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ message OpenSessionReply {
|
|||||||
repeated string capabilities = 5;
|
repeated string capabilities = 5;
|
||||||
google.protobuf.Duration default_command_timeout = 6;
|
google.protobuf.Duration default_command_timeout = 6;
|
||||||
ProtocolStatus protocol_status = 7;
|
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 {
|
message CloseSessionRequest {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<span class="badge @CssClass">@Text</span>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string? Text { get; set; }
|
||||||
|
|
||||||
|
private string CssClass => Text switch
|
||||||
|
{
|
||||||
|
"Ready" or "Healthy" => "text-bg-success",
|
||||||
|
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
|
||||||
|
"Closed" => "text-bg-secondary",
|
||||||
|
"Faulted" => "text-bg-danger",
|
||||||
|
_ => "text-bg-light text-dark border"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using MxGateway.Contracts.Proto
|
||||||
|
@using MxGateway.Server.Configuration
|
||||||
|
@using MxGateway.Server.Dashboard
|
||||||
|
@using MxGateway.Server.Dashboard.Components.Layout
|
||||||
|
@using MxGateway.Server.Dashboard.Components.Shared
|
||||||
|
@using MxGateway.Server.Workers
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardAuthenticationDefaults
|
||||||
|
{
|
||||||
|
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||||
|
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||||
|
public const string ScopeClaimType = "scope";
|
||||||
|
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||||
|
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardAuthenticationResult(
|
||||||
|
bool Succeeded,
|
||||||
|
ClaimsPrincipal? Principal,
|
||||||
|
string? FailureMessage)
|
||||||
|
{
|
||||||
|
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticationResult(true, principal, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DashboardAuthenticationResult Fail(string failureMessage)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticationResult(false, null, failureMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthenticator(
|
||||||
|
IApiKeyVerifier apiKeyVerifier,
|
||||||
|
IOptions<GatewayOptions> options) : IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||||
|
|
||||||
|
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
|
string? apiKey,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
|
||||||
|
KeyId: "authentication-disabled",
|
||||||
|
KeyPrefix: "authentication-disabled",
|
||||||
|
DisplayName: "Authentication Disabled",
|
||||||
|
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
||||||
|
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.Value.Dashboard.RequireAdminScope
|
||||||
|
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
|
||||||
|
{
|
||||||
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatAuthorizationHeader(string apiKey)
|
||||||
|
{
|
||||||
|
string trimmedApiKey = apiKey.Trim();
|
||||||
|
|
||||||
|
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? trimmedApiKey
|
||||||
|
: $"Bearer {trimmedApiKey}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
|
||||||
|
{
|
||||||
|
List<Claim> claims =
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
|
||||||
|
new Claim(ClaimTypes.Name, identity.DisplayName),
|
||||||
|
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
|
||||||
|
];
|
||||||
|
|
||||||
|
claims.AddRange(identity.Scopes.Select(scope => new Claim(
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||||
|
scope)));
|
||||||
|
|
||||||
|
ClaimsIdentity claimsIdentity = new(
|
||||||
|
claims,
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
ClaimTypes.Name,
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType);
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationHandler(
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||||
|
{
|
||||||
|
protected override Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
DashboardAuthorizationRequirement requirement)
|
||||||
|
{
|
||||||
|
GatewayOptions gatewayOptions = options.Value;
|
||||||
|
|
||||||
|
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsLoopbackRequest()
|
||||||
|
{
|
||||||
|
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
|
||||||
|
|
||||||
|
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasAdminScope(AuthorizationHandlerContext context)
|
||||||
|
{
|
||||||
|
return context.User.HasClaim(
|
||||||
|
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||||
|
GatewayScopes.Admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard.Components;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardEndpointRouteBuilderExtensions
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
|
||||||
|
{
|
||||||
|
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||||
|
IConfigurationSection dashboardSection = configuration
|
||||||
|
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
|
||||||
|
|
||||||
|
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
|
||||||
|
{
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
||||||
|
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
|
||||||
|
|
||||||
|
dashboard.MapGet(
|
||||||
|
"/login",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardLogin");
|
||||||
|
|
||||||
|
dashboard.MapPost(
|
||||||
|
"/login",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
|
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardLoginPost");
|
||||||
|
|
||||||
|
dashboard.MapPost(
|
||||||
|
"/logout",
|
||||||
|
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||||
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
|
||||||
|
.WithName("DashboardLogout");
|
||||||
|
|
||||||
|
dashboard.MapGet("/denied", () => Results.Content(
|
||||||
|
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||||
|
"text/html"))
|
||||||
|
.AllowAnonymous()
|
||||||
|
.WithName("DashboardAccessDenied");
|
||||||
|
|
||||||
|
dashboard.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode()
|
||||||
|
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||||
|
|
||||||
|
return endpoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<ContentHttpResult> GetLoginAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
string returnUrl = SanitizeReturnUrl(
|
||||||
|
httpContext.Request.Query["returnUrl"].ToString(),
|
||||||
|
pathBase);
|
||||||
|
|
||||||
|
return Task.FromResult(TypedResults.Content(
|
||||||
|
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
|
||||||
|
"text/html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PostLoginAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
IDashboardAuthenticator authenticator,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||||
|
|
||||||
|
IFormCollection form = await httpContext.Request
|
||||||
|
.ReadFormAsync(httpContext.RequestAborted)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
string returnUrl = SanitizeReturnUrl(
|
||||||
|
form["returnUrl"].ToString(),
|
||||||
|
pathBase);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator
|
||||||
|
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!result.Succeeded || result.Principal is null)
|
||||||
|
{
|
||||||
|
return TypedResults.Content(
|
||||||
|
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
|
||||||
|
"text/html",
|
||||||
|
statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
await httpContext
|
||||||
|
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.LocalRedirect(returnUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PostLogoutAsync(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string pathBase)
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||||
|
await httpContext
|
||||||
|
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Results.LocalRedirect($"{pathBase}/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderLoginPage(
|
||||||
|
HttpContext httpContext,
|
||||||
|
IAntiforgery antiforgery,
|
||||||
|
string returnUrl,
|
||||||
|
string pathBase,
|
||||||
|
string? failureMessage)
|
||||||
|
{
|
||||||
|
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||||
|
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||||
|
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
||||||
|
? string.Empty
|
||||||
|
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||||
|
|
||||||
|
string body = $"""
|
||||||
|
<section class="dashboard-login">
|
||||||
|
{alert}
|
||||||
|
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||||
|
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="apiKey" class="form-label">API key</label>
|
||||||
|
<input id="apiKey" name="apiKey" type="password" autocomplete="off" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
""";
|
||||||
|
|
||||||
|
return RenderPage("Dashboard Sign In", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RenderPage(string title, string body)
|
||||||
|
{
|
||||||
|
return $"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||||
|
</head>
|
||||||
|
<body class="dashboard-body">
|
||||||
|
<main class="container py-5">
|
||||||
|
<h1 class="h3 mb-4">{HtmlEncoder.Default.Encode(title)}</h1>
|
||||||
|
{body}
|
||||||
|
</main>
|
||||||
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePathBase(string pathBase)
|
||||||
|
{
|
||||||
|
string normalized = pathBase.TrimEnd('/');
|
||||||
|
|
||||||
|
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
? "/dashboard"
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(returnUrl)
|
||||||
|
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
||||||
|
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
|
||||||
|
{
|
||||||
|
return pathBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardFaultSummary(
|
||||||
|
string Source,
|
||||||
|
string? SessionId,
|
||||||
|
int? WorkerProcessId,
|
||||||
|
string State,
|
||||||
|
string Message,
|
||||||
|
DateTimeOffset ObservedAt);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardMetricSummary(
|
||||||
|
string Name,
|
||||||
|
long Value,
|
||||||
|
string? Dimension = null);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
internal static class DashboardRedactor
|
||||||
|
{
|
||||||
|
private static readonly string[] SensitiveTextMarkers =
|
||||||
|
[
|
||||||
|
"apikey",
|
||||||
|
"api_key",
|
||||||
|
"authorization",
|
||||||
|
"credential",
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static string? Redact(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.Contains("mxgw_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return GatewayLogRedactor.RedactClientIdentity(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SensitiveTextMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))
|
||||||
|
? GatewayLogRedactor.RedactedValue
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public static class DashboardServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddAntiforgery();
|
||||||
|
services.AddCascadingAuthenticationState();
|
||||||
|
services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents();
|
||||||
|
services
|
||||||
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
|
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||||
|
services.AddAuthorization(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy(
|
||||||
|
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||||
|
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||||
|
});
|
||||||
|
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureCookieOptions(
|
||||||
|
CookieAuthenticationOptions cookieOptions,
|
||||||
|
IOptions<GatewayOptions> gatewayOptions)
|
||||||
|
{
|
||||||
|
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||||
|
if (string.IsNullOrWhiteSpace(pathBase))
|
||||||
|
{
|
||||||
|
pathBase = "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
|
cookieOptions.Cookie.HttpOnly = true;
|
||||||
|
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||||
|
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||||
|
cookieOptions.Cookie.Path = "/";
|
||||||
|
cookieOptions.LoginPath = $"{pathBase}/login";
|
||||||
|
cookieOptions.LogoutPath = $"{pathBase}/logout";
|
||||||
|
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
|
||||||
|
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||||
|
cookieOptions.SlidingExpiration = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardSessionSummary(
|
||||||
|
string SessionId,
|
||||||
|
string BackendName,
|
||||||
|
SessionState State,
|
||||||
|
string? ClientIdentity,
|
||||||
|
string? ClientSessionName,
|
||||||
|
string? ClientCorrelationId,
|
||||||
|
DateTimeOffset OpenedAt,
|
||||||
|
DateTimeOffset LastClientActivityAt,
|
||||||
|
DateTimeOffset? LeaseExpiresAt,
|
||||||
|
int? WorkerProcessId,
|
||||||
|
WorkerClientState? WorkerState,
|
||||||
|
DateTimeOffset? LastWorkerHeartbeatAt,
|
||||||
|
string? LastFault);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardSnapshot(
|
||||||
|
DateTimeOffset GeneratedAt,
|
||||||
|
DateTimeOffset GatewayStartedAt,
|
||||||
|
TimeSpan GatewayUptime,
|
||||||
|
string GatewayStatus,
|
||||||
|
string GatewayVersion,
|
||||||
|
IReadOnlyList<DashboardSessionSummary> Sessions,
|
||||||
|
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||||
|
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||||
|
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||||
|
EffectiveGatewayConfiguration Configuration);
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||||
|
{
|
||||||
|
private const string HealthyStatus = "Healthy";
|
||||||
|
|
||||||
|
private readonly ISessionRegistry _sessionRegistry;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly IGatewayConfigurationProvider _configurationProvider;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly DateTimeOffset _gatewayStartedAt;
|
||||||
|
private readonly TimeSpan _snapshotInterval;
|
||||||
|
private readonly int _recentFaultLimit;
|
||||||
|
private readonly int _recentSessionLimit;
|
||||||
|
|
||||||
|
public DashboardSnapshotService(
|
||||||
|
ISessionRegistry sessionRegistry,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
IGatewayConfigurationProvider configurationProvider,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_gatewayStartedAt = _timeProvider.GetUtcNow();
|
||||||
|
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||||
|
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||||
|
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DashboardSnapshot GetSnapshot()
|
||||||
|
{
|
||||||
|
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
||||||
|
IReadOnlyList<GatewaySession> sessions = _sessionRegistry.Snapshot()
|
||||||
|
.OrderByDescending(session => session.OpenedAt)
|
||||||
|
.ToArray();
|
||||||
|
IReadOnlyList<DashboardSessionSummary> sessionSummaries = sessions
|
||||||
|
.Take(ResolveLimit(_recentSessionLimit))
|
||||||
|
.Select(CreateSessionSummary)
|
||||||
|
.ToArray();
|
||||||
|
IReadOnlyList<DashboardWorkerSummary> workerSummaries = sessions
|
||||||
|
.Where(session => session.WorkerClient is not null)
|
||||||
|
.Select(CreateWorkerSummary)
|
||||||
|
.ToArray();
|
||||||
|
GatewayMetricsSnapshot metricsSnapshot = _metrics.GetSnapshot();
|
||||||
|
|
||||||
|
return new DashboardSnapshot(
|
||||||
|
GeneratedAt: generatedAt,
|
||||||
|
GatewayStartedAt: _gatewayStartedAt,
|
||||||
|
GatewayUptime: generatedAt - _gatewayStartedAt,
|
||||||
|
GatewayStatus: HealthyStatus,
|
||||||
|
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||||
|
Sessions: sessionSummaries,
|
||||||
|
Workers: workerSummaries,
|
||||||
|
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||||
|
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||||
|
Configuration: _configurationProvider.GetEffectiveConfiguration());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return GetSnapshot();
|
||||||
|
|
||||||
|
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
bool hasNext;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hasNext = await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNext)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return GetSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardSessionSummary CreateSessionSummary(GatewaySession session)
|
||||||
|
{
|
||||||
|
IWorkerClient? workerClient = session.WorkerClient;
|
||||||
|
|
||||||
|
return new DashboardSessionSummary(
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
BackendName: session.BackendName,
|
||||||
|
State: session.State,
|
||||||
|
ClientIdentity: DashboardRedactor.Redact(session.ClientIdentity),
|
||||||
|
ClientSessionName: DashboardRedactor.Redact(session.ClientSessionName),
|
||||||
|
ClientCorrelationId: DashboardRedactor.Redact(session.ClientCorrelationId),
|
||||||
|
OpenedAt: session.OpenedAt,
|
||||||
|
LastClientActivityAt: session.LastClientActivityAt,
|
||||||
|
LeaseExpiresAt: session.LeaseExpiresAt,
|
||||||
|
WorkerProcessId: workerClient?.ProcessId,
|
||||||
|
WorkerState: workerClient?.State,
|
||||||
|
LastWorkerHeartbeatAt: workerClient?.LastHeartbeatAt,
|
||||||
|
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardWorkerSummary CreateWorkerSummary(GatewaySession session)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = session.WorkerClient!;
|
||||||
|
|
||||||
|
return new DashboardWorkerSummary(
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
ProcessId: workerClient.ProcessId,
|
||||||
|
State: workerClient.State,
|
||||||
|
LastHeartbeatAt: workerClient.LastHeartbeatAt,
|
||||||
|
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DashboardMetricSummary> CreateMetricSummaries(GatewayMetricsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
List<DashboardMetricSummary> metrics =
|
||||||
|
[
|
||||||
|
new("mxgateway.sessions.open", snapshot.OpenSessions),
|
||||||
|
new("mxgateway.workers.running", snapshot.WorkersRunning),
|
||||||
|
new("mxgateway.events.queue.depth", snapshot.EventQueueDepth),
|
||||||
|
new("mxgateway.sessions.opened", snapshot.SessionsOpened),
|
||||||
|
new("mxgateway.sessions.closed", snapshot.SessionsClosed),
|
||||||
|
new("mxgateway.commands.started", snapshot.CommandsStarted),
|
||||||
|
new("mxgateway.commands.succeeded", snapshot.CommandsSucceeded),
|
||||||
|
new("mxgateway.commands.failed", snapshot.CommandsFailed),
|
||||||
|
new("mxgateway.events.received", snapshot.EventsReceived),
|
||||||
|
new("mxgateway.queues.overflows", snapshot.QueueOverflows),
|
||||||
|
new("mxgateway.faults", snapshot.Faults),
|
||||||
|
new("mxgateway.workers.killed", snapshot.WorkerKills),
|
||||||
|
new("mxgateway.workers.exited", snapshot.WorkerExits),
|
||||||
|
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
|
||||||
|
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
|
||||||
|
];
|
||||||
|
|
||||||
|
metrics.AddRange(snapshot.CommandFailuresByMethod
|
||||||
|
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(entry => new DashboardMetricSummary("mxgateway.commands.failed", entry.Value, entry.Key)));
|
||||||
|
metrics.AddRange(snapshot.EventsByFamily
|
||||||
|
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(entry => new DashboardMetricSummary("mxgateway.events.received", entry.Value, entry.Key)));
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<DashboardFaultSummary> CreateFaultSummaries(
|
||||||
|
IReadOnlyList<GatewaySession> sessions,
|
||||||
|
DateTimeOffset generatedAt)
|
||||||
|
{
|
||||||
|
return sessions
|
||||||
|
.Where(HasFault)
|
||||||
|
.Take(ResolveLimit(_recentFaultLimit))
|
||||||
|
.Select(session => new DashboardFaultSummary(
|
||||||
|
Source: session.WorkerClient?.State == WorkerClientState.Faulted ? "Worker" : "Session",
|
||||||
|
SessionId: session.SessionId,
|
||||||
|
WorkerProcessId: session.WorkerProcessId,
|
||||||
|
State: session.WorkerClient?.State == WorkerClientState.Faulted
|
||||||
|
? WorkerClientState.Faulted.ToString()
|
||||||
|
: session.State.ToString(),
|
||||||
|
Message: DashboardRedactor.Redact(session.FinalFault) ?? "Faulted",
|
||||||
|
ObservedAt: generatedAt))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasFault(GatewaySession session)
|
||||||
|
{
|
||||||
|
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|
||||||
|
|| session.WorkerClient?.State == WorkerClientState.Faulted
|
||||||
|
|| !string.IsNullOrWhiteSpace(session.FinalFault);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveLimit(int configuredLimit)
|
||||||
|
{
|
||||||
|
return configuredLimit < 0 ? 0 : configuredLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public sealed record DashboardWorkerSummary(
|
||||||
|
string SessionId,
|
||||||
|
int? ProcessId,
|
||||||
|
WorkerClientState State,
|
||||||
|
DateTimeOffset LastHeartbeatAt,
|
||||||
|
string? LastFault);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public interface IDashboardAuthenticator
|
||||||
|
{
|
||||||
|
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
|
string? apiKey,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
public interface IDashboardSnapshotService
|
||||||
|
{
|
||||||
|
DashboardSnapshot GetSnapshot();
|
||||||
|
|
||||||
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
using MxGateway.Contracts;
|
using MxGateway.Contracts;
|
||||||
using MxGateway.Server.Configuration;
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
using MxGateway.Server.Diagnostics;
|
using MxGateway.Server.Diagnostics;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
using MxGateway.Server.Metrics;
|
using MxGateway.Server.Metrics;
|
||||||
using MxGateway.Server.Security.Authentication;
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
using MxGateway.Server.Workers;
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
namespace MxGateway.Server;
|
namespace MxGateway.Server;
|
||||||
@@ -15,6 +19,10 @@ public static class GatewayApplication
|
|||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
app.UseGatewayRequestLoggingScope();
|
app.UseGatewayRequestLoggingScope();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseAntiforgery();
|
||||||
app.MapGatewayEndpoints();
|
app.MapGatewayEndpoints();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -26,9 +34,15 @@ public static class GatewayApplication
|
|||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.Services.AddGatewayConfiguration();
|
||||||
builder.Services.AddSqliteAuthStore();
|
builder.Services.AddSqliteAuthStore();
|
||||||
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
||||||
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
||||||
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
|
builder.Services.AddGatewaySessions();
|
||||||
|
builder.Services.AddGatewayDashboard();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
@@ -45,6 +59,9 @@ public static class GatewayApplication
|
|||||||
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
||||||
.WithName("LiveHealth");
|
.WithName("LiveHealth");
|
||||||
|
|
||||||
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
|
endpoints.MapGatewayDashboard();
|
||||||
|
|
||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class EventStreamService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
MxAccessGrpcMapper mapper,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
ILogger<EventStreamService> logger) : IEventStreamService
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
$"Session {request.SessionId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using IDisposable subscriber = session.AttachEventSubscriber(
|
||||||
|
options.Value.Sessions.AllowMultipleEventSubscribers);
|
||||||
|
using CancellationTokenSource streamCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
|
||||||
|
int streamQueueDepth = 0;
|
||||||
|
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
|
||||||
|
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = true,
|
||||||
|
FullMode = BoundedChannelFullMode.Wait,
|
||||||
|
AllowSynchronousContinuations = false,
|
||||||
|
});
|
||||||
|
Task producerTask = ProduceEventsAsync(
|
||||||
|
session,
|
||||||
|
request.AfterWorkerSequence,
|
||||||
|
eventQueue.Writer,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
int depth = Interlocked.Increment(ref streamQueueDepth);
|
||||||
|
metrics.SetEventQueueDepth(depth);
|
||||||
|
},
|
||||||
|
streamCts.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (MxEvent mxEvent in eventQueue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
int depth = Math.Max(0, Interlocked.Decrement(ref streamQueueDepth));
|
||||||
|
metrics.SetEventQueueDepth(depth);
|
||||||
|
yield return mxEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
await producerTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await streamCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
subscriber.Dispose();
|
||||||
|
metrics.StreamDisconnected("Detached");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await producerTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (streamCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogDebug(
|
||||||
|
exception,
|
||||||
|
"Event stream producer stopped for session {SessionId}.",
|
||||||
|
request.SessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProduceEventsAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
ulong afterWorkerSequence,
|
||||||
|
ChannelWriter<MxEvent> writer,
|
||||||
|
Action eventQueued,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in session
|
||||||
|
.ReadEventsAsync(cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
MxEvent publicEvent = mapper.MapEvent(workerEvent);
|
||||||
|
if (publicEvent.WorkerSequence <= afterWorkerSequence)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writer.TryWrite(publicEvent))
|
||||||
|
{
|
||||||
|
string message = $"Session {session.SessionId} event stream queue overflowed.";
|
||||||
|
session.MarkFaulted(message);
|
||||||
|
metrics.QueueOverflow("grpc-event-stream");
|
||||||
|
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
|
||||||
|
writer.TryComplete(new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.EventQueueOverflow,
|
||||||
|
message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventQueued();
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.TryComplete();
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
writer.TryComplete();
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is WorkerClientException)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.TryComplete(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public interface IEventStreamService
|
||||||
|
{
|
||||||
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGatewayService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
|
MxAccessGrpcRequestValidator requestValidator,
|
||||||
|
MxAccessGrpcMapper mapper,
|
||||||
|
IEventStreamService eventStreamService,
|
||||||
|
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
|
||||||
|
{
|
||||||
|
public override async Task<OpenSessionReply> OpenSession(
|
||||||
|
OpenSessionRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateOpenSession(request);
|
||||||
|
GatewaySession session = await sessionManager
|
||||||
|
.OpenSessionAsync(
|
||||||
|
SessionOpenRequest.FromContract(request),
|
||||||
|
ResolveClientIdentity(),
|
||||||
|
context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
OpenSessionReply reply = new()
|
||||||
|
{
|
||||||
|
SessionId = session.SessionId,
|
||||||
|
BackendName = session.BackendName,
|
||||||
|
WorkerProcessId = session.WorkerProcessId ?? 0,
|
||||||
|
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion,
|
||||||
|
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
};
|
||||||
|
reply.Capabilities.Add("unary-open-session");
|
||||||
|
reply.Capabilities.Add("unary-close-session");
|
||||||
|
reply.Capabilities.Add("unary-invoke");
|
||||||
|
reply.Capabilities.Add("server-stream-events");
|
||||||
|
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<CloseSessionReply> CloseSession(
|
||||||
|
CloseSessionRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateCloseSession(request);
|
||||||
|
SessionCloseResult result = await sessionManager
|
||||||
|
.CloseSessionAsync(request.SessionId, context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new CloseSessionReply
|
||||||
|
{
|
||||||
|
SessionId = result.SessionId,
|
||||||
|
FinalState = result.FinalState,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(result.AlreadyClosed ? "Session was already closed." : "Session closed."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<MxCommandReply> Invoke(
|
||||||
|
MxCommandRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateInvoke(request);
|
||||||
|
WorkerCommand workerCommand = mapper.MapCommand(request);
|
||||||
|
WorkerCommandReply workerReply = await sessionManager
|
||||||
|
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return mapper.MapCommandReply(workerReply);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StreamEvents(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
IServerStreamWriter<MxEvent> responseStream,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
requestValidator.ValidateStreamEvents(request);
|
||||||
|
await foreach (MxEvent publicEvent in eventStreamService
|
||||||
|
.StreamEventsAsync(request, context.CancellationToken)
|
||||||
|
.WithCancellation(context.CancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not RpcException)
|
||||||
|
{
|
||||||
|
throw MapException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ResolveClientIdentity()
|
||||||
|
{
|
||||||
|
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private RpcException MapException(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is OperationCanceledException)
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.Cancelled, "gRPC request was canceled."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is SessionManagerException sessionException)
|
||||||
|
{
|
||||||
|
return MapSessionException(sessionException);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception is WorkerClientException workerClientException)
|
||||||
|
{
|
||||||
|
return MapWorkerClientException(workerClientException);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogWarning(exception, "Public gRPC request failed.");
|
||||||
|
return new RpcException(new Status(StatusCode.Unavailable, "Gateway request failed before an MXAccess reply was available."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException MapSessionException(SessionManagerException exception)
|
||||||
|
{
|
||||||
|
StatusCode statusCode = exception.ErrorCode switch
|
||||||
|
{
|
||||||
|
SessionManagerErrorCode.SessionNotFound => StatusCode.NotFound,
|
||||||
|
SessionManagerErrorCode.SessionNotReady => StatusCode.FailedPrecondition,
|
||||||
|
SessionManagerErrorCode.EventSubscriberAlreadyActive => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.EventQueueOverflow => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.SessionLimitExceeded => StatusCode.ResourceExhausted,
|
||||||
|
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
|
||||||
|
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
|
||||||
|
_ => StatusCode.Unavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RpcException(new Status(statusCode, exception.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException MapWorkerClientException(WorkerClientException exception)
|
||||||
|
{
|
||||||
|
StatusCode statusCode = exception.ErrorCode switch
|
||||||
|
{
|
||||||
|
WorkerClientErrorCode.CommandTimeout => StatusCode.DeadlineExceeded,
|
||||||
|
WorkerClientErrorCode.GatewayShutdown => StatusCode.Cancelled,
|
||||||
|
WorkerClientErrorCode.InvalidState => StatusCode.FailedPrecondition,
|
||||||
|
WorkerClientErrorCode.ProtocolViolation => StatusCode.Internal,
|
||||||
|
_ => StatusCode.Unavailable,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new RpcException(new Status(statusCode, exception.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGrpcMapper
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
|
||||||
|
public MxAccessGrpcMapper(TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerCommand MapCommand(MxCommandRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
ArgumentNullException.ThrowIfNull(request.Command);
|
||||||
|
|
||||||
|
return new WorkerCommand
|
||||||
|
{
|
||||||
|
Command = request.Command.Clone(),
|
||||||
|
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|
||||||
|
if (reply.Reply is null)
|
||||||
|
{
|
||||||
|
return new MxCommandReply
|
||||||
|
{
|
||||||
|
ProtocolStatus = ProtocolViolation("Worker command reply did not contain a public reply payload."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.Reply.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxEvent MapEvent(WorkerEvent workerEvent)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||||
|
|
||||||
|
return workerEvent.Event?.Clone() ?? new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.Unspecified,
|
||||||
|
RawStatus = "Worker event did not contain a public event payload.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Ok(string message = "OK")
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Ok,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus InvalidRequest(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.InvalidRequest,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus SessionNotFound(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotFound,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus SessionNotReady(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.SessionNotReady,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus WorkerUnavailable(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Timeout(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Timeout,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus Canceled(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.Canceled,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProtocolStatus ProtocolViolation(string message)
|
||||||
|
{
|
||||||
|
return new ProtocolStatus
|
||||||
|
{
|
||||||
|
Code = ProtocolStatusCode.ProtocolViolation,
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGrpcRequestValidator
|
||||||
|
{
|
||||||
|
public void ValidateOpenSession(OpenSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
if (request.CommandTimeout is not null && request.CommandTimeout.ToTimeSpan() <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Command timeout must be greater than zero when provided.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateCloseSession(CloseSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateStreamEvents(StreamEventsRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ValidateInvoke(MxCommandRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
RequireSessionId(request.SessionId);
|
||||||
|
|
||||||
|
if (request.Command is null)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Invoke requires a command payload.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Command.Kind is MxCommandKind.Unspecified)
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Invoke requires a command kind.");
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateCommandPayload(request.Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RequireSessionId(string sessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw InvalidArgument("Session id is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateCommandPayload(MxCommand command)
|
||||||
|
{
|
||||||
|
MxCommand.PayloadOneofCase expectedPayload = ExpectedPayload(command.Kind);
|
||||||
|
if (command.PayloadCase != expectedPayload)
|
||||||
|
{
|
||||||
|
throw InvalidArgument(
|
||||||
|
$"Command kind {command.Kind} requires payload {expectedPayload} but received {command.PayloadCase}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommand.PayloadOneofCase ExpectedPayload(MxCommandKind kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
MxCommandKind.Register => MxCommand.PayloadOneofCase.Register,
|
||||||
|
MxCommandKind.Unregister => MxCommand.PayloadOneofCase.Unregister,
|
||||||
|
MxCommandKind.AddItem => MxCommand.PayloadOneofCase.AddItem,
|
||||||
|
MxCommandKind.AddItem2 => MxCommand.PayloadOneofCase.AddItem2,
|
||||||
|
MxCommandKind.RemoveItem => MxCommand.PayloadOneofCase.RemoveItem,
|
||||||
|
MxCommandKind.Advise => MxCommand.PayloadOneofCase.Advise,
|
||||||
|
MxCommandKind.UnAdvise => MxCommand.PayloadOneofCase.UnAdvise,
|
||||||
|
MxCommandKind.AdviseSupervisory => MxCommand.PayloadOneofCase.AdviseSupervisory,
|
||||||
|
MxCommandKind.AddBufferedItem => MxCommand.PayloadOneofCase.AddBufferedItem,
|
||||||
|
MxCommandKind.SetBufferedUpdateInterval => MxCommand.PayloadOneofCase.SetBufferedUpdateInterval,
|
||||||
|
MxCommandKind.Suspend => MxCommand.PayloadOneofCase.Suspend,
|
||||||
|
MxCommandKind.Activate => MxCommand.PayloadOneofCase.Activate,
|
||||||
|
MxCommandKind.Write => MxCommand.PayloadOneofCase.Write,
|
||||||
|
MxCommandKind.Write2 => MxCommand.PayloadOneofCase.Write2,
|
||||||
|
MxCommandKind.WriteSecured => MxCommand.PayloadOneofCase.WriteSecured,
|
||||||
|
MxCommandKind.WriteSecured2 => MxCommand.PayloadOneofCase.WriteSecured2,
|
||||||
|
MxCommandKind.AuthenticateUser => MxCommand.PayloadOneofCase.AuthenticateUser,
|
||||||
|
MxCommandKind.ArchestraUserToId => MxCommand.PayloadOneofCase.ArchestraUserToId,
|
||||||
|
MxCommandKind.Ping => MxCommand.PayloadOneofCase.Ping,
|
||||||
|
MxCommandKind.GetSessionState => MxCommand.PayloadOneofCase.GetSessionState,
|
||||||
|
MxCommandKind.GetWorkerInfo => MxCommand.PayloadOneofCase.GetWorkerInfo,
|
||||||
|
MxCommandKind.DrainEvents => MxCommand.PayloadOneofCase.DrainEvents,
|
||||||
|
MxCommandKind.ShutdownWorker => MxCommand.PayloadOneofCase.ShutdownWorker,
|
||||||
|
_ => MxCommand.PayloadOneofCase.None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RpcException InvalidArgument(string detail)
|
||||||
|
{
|
||||||
|
return new RpcException(new Status(StatusCode.InvalidArgument, detail));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Core.Interceptors;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public sealed class GatewayGrpcAuthorizationInterceptor(
|
||||||
|
IApiKeyVerifier apiKeyVerifier,
|
||||||
|
GatewayGrpcScopeResolver scopeResolver,
|
||||||
|
IGatewayRequestIdentityAccessor identityAccessor,
|
||||||
|
IOptions<GatewayOptions> options) : Interceptor
|
||||||
|
{
|
||||||
|
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||||
|
TRequest request,
|
||||||
|
ServerCallContext context,
|
||||||
|
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||||
|
{
|
||||||
|
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
|
||||||
|
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
|
||||||
|
using (identityScope)
|
||||||
|
{
|
||||||
|
return await continuation(request, context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||||
|
TRequest request,
|
||||||
|
IServerStreamWriter<TResponse> responseStream,
|
||||||
|
ServerCallContext context,
|
||||||
|
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||||
|
{
|
||||||
|
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
|
||||||
|
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
|
||||||
|
using (identityScope)
|
||||||
|
{
|
||||||
|
await continuation(request, responseStream, context).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ApiKeyIdentity?> AuthenticateAndAuthorizeAsync<TRequest>(
|
||||||
|
TRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
where TRequest : class
|
||||||
|
{
|
||||||
|
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
|
||||||
|
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
||||||
|
.VerifyAsync(authorizationHeader, context.CancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.Unauthenticated,
|
||||||
|
"Missing or invalid API key."));
|
||||||
|
}
|
||||||
|
|
||||||
|
string requiredScope = scopeResolver.ResolveRequiredScope(request);
|
||||||
|
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(
|
||||||
|
StatusCode.PermissionDenied,
|
||||||
|
$"API key is missing required scope '{requiredScope}'."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return verificationResult.Identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public sealed class GatewayGrpcScopeResolver
|
||||||
|
{
|
||||||
|
public string ResolveRequiredScope(object request)
|
||||||
|
{
|
||||||
|
return request switch
|
||||||
|
{
|
||||||
|
OpenSessionRequest => GatewayScopes.SessionOpen,
|
||||||
|
CloseSessionRequest => GatewayScopes.SessionClose,
|
||||||
|
StreamEventsRequest => GatewayScopes.EventsRead,
|
||||||
|
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
|
||||||
|
_ => GatewayScopes.Admin
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveCommandScope(MxCommandKind kind)
|
||||||
|
{
|
||||||
|
return kind switch
|
||||||
|
{
|
||||||
|
MxCommandKind.Write or
|
||||||
|
MxCommandKind.Write2 => GatewayScopes.InvokeWrite,
|
||||||
|
|
||||||
|
MxCommandKind.WriteSecured or
|
||||||
|
MxCommandKind.WriteSecured2 or
|
||||||
|
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
|
||||||
|
|
||||||
|
MxCommandKind.ArchestraUserToId or
|
||||||
|
MxCommandKind.GetSessionState or
|
||||||
|
MxCommandKind.GetWorkerInfo => GatewayScopes.MetadataRead,
|
||||||
|
|
||||||
|
MxCommandKind.DrainEvents => GatewayScopes.EventsRead,
|
||||||
|
MxCommandKind.ShutdownWorker => GatewayScopes.Admin,
|
||||||
|
|
||||||
|
_ => GatewayScopes.InvokeRead
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor
|
||||||
|
{
|
||||||
|
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
|
||||||
|
|
||||||
|
public ApiKeyIdentity? Current => currentIdentity.Value;
|
||||||
|
|
||||||
|
public IDisposable Push(ApiKeyIdentity identity)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(identity);
|
||||||
|
|
||||||
|
ApiKeyIdentity? previousIdentity = currentIdentity.Value;
|
||||||
|
currentIdentity.Value = identity;
|
||||||
|
|
||||||
|
return new IdentityScope(this, previousIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class IdentityScope(
|
||||||
|
GatewayRequestIdentityAccessor accessor,
|
||||||
|
ApiKeyIdentity? previousIdentity) : IDisposable
|
||||||
|
{
|
||||||
|
private bool disposed;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
accessor.currentIdentity.Value = previousIdentity;
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public static class GatewayScopes
|
||||||
|
{
|
||||||
|
public const string SessionOpen = "session:open";
|
||||||
|
public const string SessionClose = "session:close";
|
||||||
|
public const string InvokeRead = "invoke:read";
|
||||||
|
public const string InvokeWrite = "invoke:write";
|
||||||
|
public const string InvokeSecure = "invoke:secure";
|
||||||
|
public const string EventsRead = "events:read";
|
||||||
|
public const string MetadataRead = "metadata:read";
|
||||||
|
public const string Admin = "admin";
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
using Grpc.Core.Interceptors;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public static class GrpcAuthorizationServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||||
|
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||||
|
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||||
|
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
public interface IGatewayRequestIdentityAccessor
|
||||||
|
{
|
||||||
|
ApiKeyIdentity? Current { get; }
|
||||||
|
|
||||||
|
IDisposable Push(ApiKeyIdentity identity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class GatewaySession
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly SemaphoreSlim _closeLock = new(1, 1);
|
||||||
|
private IWorkerClient? _workerClient;
|
||||||
|
private SessionState _state = SessionState.Creating;
|
||||||
|
private string? _finalFault;
|
||||||
|
private DateTimeOffset _lastClientActivityAt;
|
||||||
|
private DateTimeOffset? _leaseExpiresAt;
|
||||||
|
private bool _closeStarted;
|
||||||
|
private int _activeEventSubscriberCount;
|
||||||
|
|
||||||
|
public GatewaySession(
|
||||||
|
string sessionId,
|
||||||
|
string backendName,
|
||||||
|
string pipeName,
|
||||||
|
string nonce,
|
||||||
|
string? clientIdentity,
|
||||||
|
string? clientSessionName,
|
||||||
|
string? clientCorrelationId,
|
||||||
|
TimeSpan commandTimeout,
|
||||||
|
TimeSpan startupTimeout,
|
||||||
|
TimeSpan shutdownTimeout,
|
||||||
|
DateTimeOffset openedAt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Session id is required.", nameof(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(backendName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Backend name is required.", nameof(backendName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(pipeName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Pipe name is required.", nameof(pipeName));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(nonce))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Nonce is required.", nameof(nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionId = sessionId;
|
||||||
|
BackendName = backendName;
|
||||||
|
PipeName = pipeName;
|
||||||
|
Nonce = nonce;
|
||||||
|
ClientIdentity = clientIdentity;
|
||||||
|
ClientSessionName = clientSessionName;
|
||||||
|
ClientCorrelationId = clientCorrelationId;
|
||||||
|
CommandTimeout = commandTimeout;
|
||||||
|
StartupTimeout = startupTimeout;
|
||||||
|
ShutdownTimeout = shutdownTimeout;
|
||||||
|
OpenedAt = openedAt;
|
||||||
|
_lastClientActivityAt = openedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId { get; }
|
||||||
|
|
||||||
|
public string BackendName { get; }
|
||||||
|
|
||||||
|
public string PipeName { get; }
|
||||||
|
|
||||||
|
public string Nonce { get; }
|
||||||
|
|
||||||
|
public string? ClientIdentity { get; }
|
||||||
|
|
||||||
|
public string? ClientSessionName { get; }
|
||||||
|
|
||||||
|
public string? ClientCorrelationId { get; }
|
||||||
|
|
||||||
|
public TimeSpan CommandTimeout { get; }
|
||||||
|
|
||||||
|
public TimeSpan StartupTimeout { get; }
|
||||||
|
|
||||||
|
public TimeSpan ShutdownTimeout { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset OpenedAt { get; }
|
||||||
|
|
||||||
|
public int? WorkerProcessId => _workerClient?.ProcessId;
|
||||||
|
|
||||||
|
public IWorkerClient? WorkerClient => _workerClient;
|
||||||
|
|
||||||
|
public SessionState State
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastClientActivityAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _lastClientActivityAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset? LeaseExpiresAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _leaseExpiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? FinalFault
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _finalFault;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ActiveEventSubscriberCount
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _activeEventSubscriberCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AttachWorkerClient(IWorkerClient workerClient)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workerClient);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_workerClient = workerClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TransitionTo(SessionState nextState)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_state is SessionState.Faulted && nextState is not SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = nextState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkReady()
|
||||||
|
{
|
||||||
|
TransitionTo(SessionState.Ready);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkFaulted(string reason)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_finalFault = reason;
|
||||||
|
_state = SessionState.Faulted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TouchClientActivity(DateTimeOffset activityAt)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_lastClientActivityAt = activityAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExtendLease(DateTimeOffset leaseExpiresAt)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_leaseExpiresAt = leaseExpiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsLeaseExpired(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IDisposable AttachEventSubscriber(bool allowMultipleSubscribers)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotReady,
|
||||||
|
$"Session {SessionId} is not ready for event streaming. Current state is {_state}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowMultipleSubscribers && _activeEventSubscriberCount > 0)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.EventSubscriberAlreadyActive,
|
||||||
|
$"Session {SessionId} already has an active event stream subscriber.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeEventSubscriberCount++;
|
||||||
|
return new EventSubscriberLease(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = GetReadyWorkerClient();
|
||||||
|
TouchClientActivity(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IWorkerClient workerClient = GetReadyWorkerClient();
|
||||||
|
TouchClientActivity(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
return workerClient.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SessionCloseResult> CloseAsync(
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _closeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_state is SessionState.Closed)
|
||||||
|
{
|
||||||
|
return new SessionCloseResult(SessionId, SessionState.Closed, AlreadyClosed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool alreadyClosing = _closeStarted;
|
||||||
|
_closeStarted = true;
|
||||||
|
_state = SessionState.Closing;
|
||||||
|
|
||||||
|
if (_workerClient is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _workerClient.ShutdownAsync(ShutdownTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_workerClient.Kill(reason);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = SessionState.Closed;
|
||||||
|
return new SessionCloseResult(SessionId, SessionState.Closed, alreadyClosing);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_closeLock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void KillWorker(string reason)
|
||||||
|
{
|
||||||
|
_workerClient?.Kill(reason);
|
||||||
|
TransitionTo(SessionState.Closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_closeLock.Dispose();
|
||||||
|
if (_workerClient is not null)
|
||||||
|
{
|
||||||
|
await _workerClient.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IWorkerClient GetReadyWorkerClient()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state != SessionState.Ready || _workerClient?.State != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotReady,
|
||||||
|
$"Session {SessionId} is not ready. Current state is {_state}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return _workerClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DetachEventSubscriber()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_activeEventSubscriberCount > 0)
|
||||||
|
{
|
||||||
|
_activeEventSubscriberCount--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class EventSubscriberLease(GatewaySession session) : IDisposable
|
||||||
|
{
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.DetachEventSubscriber();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionManager
|
||||||
|
{
|
||||||
|
Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session);
|
||||||
|
|
||||||
|
Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionRegistry
|
||||||
|
{
|
||||||
|
int Count { get; }
|
||||||
|
|
||||||
|
int ActiveCount { get; }
|
||||||
|
|
||||||
|
bool TryAdd(GatewaySession session);
|
||||||
|
|
||||||
|
bool TryGet(string sessionId, out GatewaySession session);
|
||||||
|
|
||||||
|
bool TryRemove(string sessionId, out GatewaySession session);
|
||||||
|
|
||||||
|
IReadOnlyCollection<GatewaySession> Snapshot();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public interface ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
Task<MxGateway.Server.Workers.IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed record SessionCloseResult(
|
||||||
|
string SessionId,
|
||||||
|
SessionState FinalState,
|
||||||
|
bool AlreadyClosed);
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionManager : ISessionManager
|
||||||
|
{
|
||||||
|
public const string DefaultCloseReason = "client-close";
|
||||||
|
public const string GatewayShutdownReason = "gateway-shutdown";
|
||||||
|
public const string LeaseExpiredReason = "lease-expired";
|
||||||
|
|
||||||
|
private readonly ISessionRegistry _registry;
|
||||||
|
private readonly ISessionWorkerClientFactory _workerClientFactory;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<SessionManager> _logger;
|
||||||
|
private readonly GatewayOptions _options;
|
||||||
|
|
||||||
|
public SessionManager(
|
||||||
|
ISessionRegistry registry,
|
||||||
|
ISessionWorkerClientFactory workerClientFactory,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
ILogger<SessionManager>? logger = null)
|
||||||
|
{
|
||||||
|
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||||
|
_workerClientFactory = workerClientFactory ?? throw new ArgumentNullException(nameof(workerClientFactory));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_logger = logger ?? NullLogger<SessionManager>.Instance;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
EnsureSessionCapacity();
|
||||||
|
|
||||||
|
GatewaySession session = CreateSession(request, clientIdentity);
|
||||||
|
if (!_registry.TryAdd(session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.OpenFailed,
|
||||||
|
$"Session id collision while opening session {session.SessionId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.TransitionTo(SessionState.StartingWorker);
|
||||||
|
IWorkerClient workerClient = await _workerClientFactory
|
||||||
|
.CreateAsync(session, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
_metrics.SessionOpened();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
_registry.TryRemove(session.SessionId, out _);
|
||||||
|
await session.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Failed to open gateway session {SessionId}.",
|
||||||
|
session.SessionId);
|
||||||
|
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.OpenFailed,
|
||||||
|
$"Failed to open session {session.SessionId}.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _registry.TryGet(sessionId, out session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await session.InvokeAsync(command, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SessionManagerException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
if (session.WorkerClient?.State == WorkerClientState.Faulted)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
|
||||||
|
return session.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
GatewaySession session = GetRequiredSession(sessionId);
|
||||||
|
SessionCloseResult result = await CloseSessionCoreAsync(
|
||||||
|
session,
|
||||||
|
DefaultCloseReason,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
int closedCount = 0;
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
if (!session.IsLeaseExpired(now))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await CloseSessionCoreAsync(session, LeaseExpiredReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
closedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return closedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Graceful shutdown failed for session {SessionId}; killing worker.",
|
||||||
|
session.SessionId);
|
||||||
|
session.KillWorker(GatewayShutdownReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SessionCloseResult> CloseSessionCoreAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
string reason,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool wasClosed = session.State == SessionState.Closed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SessionCloseResult result = await session.CloseAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!wasClosed && !result.AlreadyClosed)
|
||||||
|
{
|
||||||
|
_metrics.SessionClosed();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
session.MarkFaulted(exception.Message);
|
||||||
|
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.CloseFailed,
|
||||||
|
$"Failed to close session {session.SessionId}.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GatewaySession GetRequiredSession(string sessionId)
|
||||||
|
{
|
||||||
|
if (!_registry.TryGet(sessionId, out GatewaySession session))
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
$"Session {sessionId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSessionCapacity()
|
||||||
|
{
|
||||||
|
if (_registry.ActiveCount >= _options.Sessions.MaxSessions)
|
||||||
|
{
|
||||||
|
throw new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionLimitExceeded,
|
||||||
|
$"Gateway session limit {_options.Sessions.MaxSessions} has been reached.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GatewaySession CreateSession(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity)
|
||||||
|
{
|
||||||
|
string sessionId = CreateSessionId();
|
||||||
|
string backendName = string.IsNullOrWhiteSpace(request.RequestedBackend)
|
||||||
|
? GatewayContractInfo.DefaultBackendName
|
||||||
|
: request.RequestedBackend!;
|
||||||
|
TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout);
|
||||||
|
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
|
||||||
|
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
|
||||||
|
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
|
||||||
|
string nonce = CreateNonce();
|
||||||
|
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||||
|
|
||||||
|
return new GatewaySession(
|
||||||
|
sessionId,
|
||||||
|
backendName,
|
||||||
|
pipeName,
|
||||||
|
nonce,
|
||||||
|
clientIdentity,
|
||||||
|
request.ClientSessionName,
|
||||||
|
request.ClientCorrelationId,
|
||||||
|
commandTimeout,
|
||||||
|
startupTimeout,
|
||||||
|
shutdownTimeout,
|
||||||
|
openedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeSpan ResolveCommandTimeout(Duration? requestedTimeout)
|
||||||
|
{
|
||||||
|
if (requestedTimeout is null)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromSeconds(_options.Sessions.DefaultCommandTimeoutSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan timeout = requestedTimeout.ToTimeSpan();
|
||||||
|
return timeout <= TimeSpan.Zero
|
||||||
|
? TimeSpan.FromSeconds(_options.Sessions.DefaultCommandTimeoutSeconds)
|
||||||
|
: timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateSessionId()
|
||||||
|
{
|
||||||
|
return $"session-{Guid.NewGuid():N}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateNonce()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public enum SessionManagerErrorCode
|
||||||
|
{
|
||||||
|
SessionNotFound,
|
||||||
|
SessionNotReady,
|
||||||
|
EventSubscriberAlreadyActive,
|
||||||
|
EventQueueOverflow,
|
||||||
|
SessionLimitExceeded,
|
||||||
|
OpenFailed,
|
||||||
|
CloseFailed,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionManagerException : Exception
|
||||||
|
{
|
||||||
|
public SessionManagerException(
|
||||||
|
SessionManagerErrorCode errorCode,
|
||||||
|
string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionManagerException(
|
||||||
|
SessionManagerErrorCode errorCode,
|
||||||
|
string message,
|
||||||
|
Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SessionManagerErrorCode ErrorCode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed record SessionOpenRequest(
|
||||||
|
string? RequestedBackend,
|
||||||
|
string? ClientSessionName,
|
||||||
|
string? ClientCorrelationId,
|
||||||
|
Duration? CommandTimeout)
|
||||||
|
{
|
||||||
|
public static SessionOpenRequest FromContract(OpenSessionRequest request)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return new SessionOpenRequest(
|
||||||
|
request.RequestedBackend,
|
||||||
|
request.ClientSessionName,
|
||||||
|
request.ClientCorrelationId,
|
||||||
|
request.CommandTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionRegistry : ISessionRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, GatewaySession> _sessions = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public int Count => _sessions.Count;
|
||||||
|
|
||||||
|
public int ActiveCount => _sessions.Values.Count(session => session.State is not SessionState.Closed);
|
||||||
|
|
||||||
|
public bool TryAdd(GatewaySession session)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
return _sessions.TryAdd(session.SessionId, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGet(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _sessions.TryGetValue(sessionId, out session!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRemove(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
return _sessions.TryRemove(sessionId, out session!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyCollection<GatewaySession> Snapshot()
|
||||||
|
{
|
||||||
|
return _sessions.Values.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public static class SessionServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGatewaySessions(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
||||||
|
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
||||||
|
services.AddSingleton<ISessionManager, SessionManager>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.IO.Pipes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Sessions;
|
||||||
|
|
||||||
|
public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
|
||||||
|
{
|
||||||
|
private readonly IWorkerProcessLauncher _workerProcessLauncher;
|
||||||
|
private readonly GatewayMetrics _metrics;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
|
private readonly GatewayOptions _options;
|
||||||
|
|
||||||
|
public SessionWorkerClientFactory(
|
||||||
|
IWorkerProcessLauncher workerProcessLauncher,
|
||||||
|
IOptions<GatewayOptions> options,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
ILoggerFactory loggerFactory,
|
||||||
|
TimeProvider? timeProvider = null)
|
||||||
|
{
|
||||||
|
_workerProcessLauncher = workerProcessLauncher ?? throw new ArgumentNullException(nameof(workerProcessLauncher));
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IWorkerClient> CreateAsync(
|
||||||
|
GatewaySession session,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(session);
|
||||||
|
|
||||||
|
NamedPipeServerStream? pipe = CreatePipe(session.PipeName);
|
||||||
|
WorkerProcessHandle? processHandle = null;
|
||||||
|
IWorkerClient? workerClient = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.TransitionTo(SessionState.StartingWorker);
|
||||||
|
processHandle = await _workerProcessLauncher
|
||||||
|
.LaunchAsync(
|
||||||
|
new WorkerProcessLaunchRequest(
|
||||||
|
session.SessionId,
|
||||||
|
session.PipeName,
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
session.Nonce,
|
||||||
|
pipe),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.WaitingForPipe);
|
||||||
|
await WaitForPipeConnectionAsync(pipe, session.StartupTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.Handshaking);
|
||||||
|
WorkerFrameProtocolOptions frameOptions = new(
|
||||||
|
session.SessionId,
|
||||||
|
GatewayContractInfo.WorkerProtocolVersion,
|
||||||
|
_options.Worker.MaxMessageBytes);
|
||||||
|
WorkerClientConnection connection = new(
|
||||||
|
session.SessionId,
|
||||||
|
session.Nonce,
|
||||||
|
pipe,
|
||||||
|
frameOptions,
|
||||||
|
processHandle);
|
||||||
|
WorkerClientOptions clientOptions = new()
|
||||||
|
{
|
||||||
|
HeartbeatGrace = TimeSpan.FromSeconds(_options.Worker.HeartbeatGraceSeconds),
|
||||||
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(_options.Worker.HeartbeatIntervalSeconds),
|
||||||
|
EventChannelCapacity = _options.Events.QueueCapacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
workerClient = new WorkerClient(
|
||||||
|
connection,
|
||||||
|
clientOptions,
|
||||||
|
_metrics,
|
||||||
|
_timeProvider,
|
||||||
|
_loggerFactory.CreateLogger<WorkerClient>());
|
||||||
|
|
||||||
|
pipe = null;
|
||||||
|
processHandle = null;
|
||||||
|
|
||||||
|
session.TransitionTo(SessionState.InitializingWorker);
|
||||||
|
await workerClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return workerClient;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (workerClient is not null)
|
||||||
|
{
|
||||||
|
await workerClient.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (processHandle is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!processHandle.Process.HasExited)
|
||||||
|
{
|
||||||
|
processHandle.Process.Kill(entireProcessTree: true);
|
||||||
|
_metrics.WorkerKilled("OpenSessionFailed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
processHandle.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NamedPipeServerStream CreatePipe(string pipeName)
|
||||||
|
{
|
||||||
|
return new NamedPipeServerStream(
|
||||||
|
pipeName,
|
||||||
|
PipeDirection.InOut,
|
||||||
|
maxNumberOfServerInstances: 1,
|
||||||
|
PipeTransmissionMode.Byte,
|
||||||
|
PipeOptions.Asynchronous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForPipeConnectionAsync(
|
||||||
|
NamedPipeServerStream pipe,
|
||||||
|
TimeSpan startupTimeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(startupTimeout);
|
||||||
|
await pipe.WaitForConnectionAsync(timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public interface IWorkerClient : IAsyncDisposable
|
||||||
|
{
|
||||||
|
string SessionId { get; }
|
||||||
|
|
||||||
|
int? ProcessId { get; }
|
||||||
|
|
||||||
|
WorkerClientState State { get; }
|
||||||
|
|
||||||
|
DateTimeOffset LastHeartbeatAt { get; }
|
||||||
|
|
||||||
|
Task StartAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
void Kill(string reason);
|
||||||
|
}
|
||||||
@@ -0,0 +1,757 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public sealed class WorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
private const string GatewayVersionFallback = "unknown";
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly WorkerClientConnection _connection;
|
||||||
|
private readonly WorkerClientOptions _options;
|
||||||
|
private readonly GatewayMetrics? _metrics;
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly ILogger<WorkerClient> _logger;
|
||||||
|
private readonly WorkerFrameReader _reader;
|
||||||
|
private readonly WorkerFrameWriter _writer;
|
||||||
|
private readonly Channel<WorkerEnvelope> _outboundEnvelopes;
|
||||||
|
private readonly Channel<WorkerEvent> _events;
|
||||||
|
private readonly ConcurrentDictionary<string, PendingCommand> _pendingCommands = new(StringComparer.Ordinal);
|
||||||
|
private readonly CancellationTokenSource _stopCts = new();
|
||||||
|
private long _nextSequence;
|
||||||
|
private WorkerClientState _state;
|
||||||
|
private DateTimeOffset _lastHeartbeatAt;
|
||||||
|
private int? _processId;
|
||||||
|
private int _eventQueueDepth;
|
||||||
|
private Task? _readLoopTask;
|
||||||
|
private Task? _writeLoopTask;
|
||||||
|
private Task? _heartbeatLoopTask;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public WorkerClient(
|
||||||
|
WorkerClientConnection connection,
|
||||||
|
WorkerClientOptions? options = null,
|
||||||
|
GatewayMetrics? metrics = null,
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
ILogger<WorkerClient>? logger = null)
|
||||||
|
{
|
||||||
|
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||||
|
_options = options ?? new WorkerClientOptions();
|
||||||
|
_metrics = metrics;
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_logger = logger ?? NullLogger<WorkerClient>.Instance;
|
||||||
|
_reader = new WorkerFrameReader(connection.Stream, connection.FrameOptions);
|
||||||
|
_writer = new WorkerFrameWriter(connection.Stream, connection.FrameOptions);
|
||||||
|
_outboundEnvelopes = Channel.CreateUnbounded<WorkerEnvelope>(
|
||||||
|
new UnboundedChannelOptions
|
||||||
|
{
|
||||||
|
SingleReader = true,
|
||||||
|
SingleWriter = false,
|
||||||
|
AllowSynchronousContinuations = false,
|
||||||
|
});
|
||||||
|
_events = Channel.CreateBounded<WorkerEvent>(
|
||||||
|
new BoundedChannelOptions(_options.EventChannelCapacity)
|
||||||
|
{
|
||||||
|
SingleReader = false,
|
||||||
|
SingleWriter = true,
|
||||||
|
FullMode = BoundedChannelFullMode.Wait,
|
||||||
|
AllowSynchronousContinuations = false,
|
||||||
|
});
|
||||||
|
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId => _connection.SessionId;
|
||||||
|
|
||||||
|
public int? ProcessId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _processId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerClientState State
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _lastHeartbeatAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
TransitionFromCreatedToHandshaking();
|
||||||
|
|
||||||
|
_writeLoopTask = Task.Run(WriteLoopAsync);
|
||||||
|
await EnqueueAsync(CreateGatewayHelloEnvelope(), cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
WorkerEnvelope helloEnvelope = await ReadHandshakeEnvelopeAsync(
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerHello,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
ValidateWorkerHello(helloEnvelope.WorkerHello);
|
||||||
|
|
||||||
|
WorkerEnvelope readyEnvelope = await ReadHandshakeEnvelopeAsync(
|
||||||
|
WorkerEnvelope.BodyOneofCase.WorkerReady,
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
MarkReady(readyEnvelope.WorkerReady);
|
||||||
|
|
||||||
|
_readLoopTask = Task.Run(ReadLoopAsync);
|
||||||
|
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
ThrowIfDisposed();
|
||||||
|
EnsureReady();
|
||||||
|
|
||||||
|
if (timeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Command timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
string method = GetCommandMethod(command);
|
||||||
|
PendingCommand pendingCommand = new(
|
||||||
|
correlationId,
|
||||||
|
method,
|
||||||
|
_timeProvider.GetTimestamp());
|
||||||
|
|
||||||
|
if (!_pendingCommands.TryAdd(correlationId, pendingCommand))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Generated a duplicate command correlation id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_metrics?.CommandStarted(method);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnqueueAsync(CreateCommandEnvelope(correlationId, command), cancellationToken).ConfigureAwait(false);
|
||||||
|
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
Task timeoutTask = Task.Delay(timeout, timeoutCts.Token);
|
||||||
|
Task<WorkerCommandReply> replyTask = pendingCommand.Task;
|
||||||
|
Task completedTask = await Task.WhenAny(replyTask, timeoutTask).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (completedTask == replyTask)
|
||||||
|
{
|
||||||
|
await timeoutCts.CancelAsync().ConfigureAwait(false);
|
||||||
|
return await replyTask.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
RemovePendingCommandAsFailed(
|
||||||
|
correlationId,
|
||||||
|
pendingCommand,
|
||||||
|
WorkerClientErrorCode.GatewayShutdown,
|
||||||
|
"Command wait was canceled.");
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
RemovePendingCommandAsFailed(
|
||||||
|
correlationId,
|
||||||
|
pendingCommand,
|
||||||
|
WorkerClientErrorCode.CommandTimeout,
|
||||||
|
$"Worker command {method} timed out after {timeout}.");
|
||||||
|
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.CommandTimeout,
|
||||||
|
$"Worker command {method} timed out after {timeout}.");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_pendingCommands.TryRemove(correlationId, out _);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
int queueDepth = Math.Max(0, Interlocked.Decrement(ref _eventQueueDepth));
|
||||||
|
_metrics?.SetEventQueueDepth(queueDepth);
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
if (timeout <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Shutdown timeout must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkerClientState state = State;
|
||||||
|
if (state is WorkerClientState.Closed or WorkerClientState.Faulted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MarkClosing();
|
||||||
|
await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false);
|
||||||
|
_outboundEnvelopes.Writer.TryComplete();
|
||||||
|
|
||||||
|
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(timeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForBackgroundTasksAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||||
|
MarkClosed("shutdown");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.ShutdownTimeout,
|
||||||
|
"Worker shutdown timed out.",
|
||||||
|
null);
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.ShutdownTimeout,
|
||||||
|
$"Worker shutdown timed out after {timeout}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
_connection.ProcessHandle?.Process.Kill(entireProcessTree: true);
|
||||||
|
_metrics?.WorkerKilled(reason);
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.WorkerFaulted,
|
||||||
|
$"Worker was killed by the gateway: {reason}.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_stopCts.Cancel();
|
||||||
|
_outboundEnvelopes.Writer.TryComplete();
|
||||||
|
_events.Writer.TryComplete();
|
||||||
|
CompletePendingCommands(
|
||||||
|
new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.GatewayShutdown,
|
||||||
|
"Worker client was disposed."));
|
||||||
|
|
||||||
|
await WaitForBackgroundTasksAsync(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
await _connection.Stream.DisposeAsync().ConfigureAwait(false);
|
||||||
|
_connection.ProcessHandle?.Dispose();
|
||||||
|
_stopCts.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.WriteFailed,
|
||||||
|
"Worker pipe write failed.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_stopCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = await _reader.ReadAsync(_stopCts.Token).ConfigureAwait(false);
|
||||||
|
await DispatchEnvelopeAsync(envelope, _stopCts.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (WorkerFrameProtocolException exception) when (exception.ErrorCode == WorkerFrameProtocolErrorCode.EndOfStream)
|
||||||
|
{
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.PipeDisconnected,
|
||||||
|
"Worker pipe disconnected.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
"Worker read loop failed.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HeartbeatLoopAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (!_stopCts.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(_options.HeartbeatCheckInterval, _stopCts.Token).ConfigureAwait(false);
|
||||||
|
if (State != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset lastHeartbeatAt = LastHeartbeatAt;
|
||||||
|
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||||||
|
if (now - lastHeartbeatAt <= _options.HeartbeatGrace)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_metrics?.HeartbeatFailed(SessionId);
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.HeartbeatExpired,
|
||||||
|
$"Worker heartbeat expired. Last heartbeat was at {lastHeartbeatAt:O}.",
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DispatchEnvelopeAsync(
|
||||||
|
WorkerEnvelope envelope,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
switch (envelope.BodyCase)
|
||||||
|
{
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerCommandReply:
|
||||||
|
CompleteCommand(envelope);
|
||||||
|
break;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerEvent:
|
||||||
|
await EnqueueWorkerEventAsync(envelope.WorkerEvent, cancellationToken).ConfigureAwait(false);
|
||||||
|
break;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerHeartbeat:
|
||||||
|
MarkHeartbeat(envelope.WorkerHeartbeat);
|
||||||
|
break;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerFault:
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.WorkerFaulted,
|
||||||
|
CreateWorkerFaultMessage(envelope.WorkerFault),
|
||||||
|
null);
|
||||||
|
break;
|
||||||
|
case WorkerEnvelope.BodyOneofCase.WorkerShutdownAck:
|
||||||
|
MarkClosed("worker-shutdown-ack");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
$"Worker sent unexpected envelope body {envelope.BodyCase}.",
|
||||||
|
null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnqueueWorkerEventAsync(
|
||||||
|
WorkerEvent workerEvent,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (workerEvent.Event is not null)
|
||||||
|
{
|
||||||
|
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_events.Writer.TryWrite(workerEvent))
|
||||||
|
{
|
||||||
|
_metrics?.QueueOverflow("worker-events");
|
||||||
|
SetFaulted(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
"Worker event channel rejected an event.",
|
||||||
|
null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int queueDepth = Interlocked.Increment(ref _eventQueueDepth);
|
||||||
|
_metrics?.SetEventQueueDepth(queueDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompleteCommand(WorkerEnvelope envelope)
|
||||||
|
{
|
||||||
|
string correlationId = envelope.CorrelationId;
|
||||||
|
if (string.IsNullOrWhiteSpace(correlationId))
|
||||||
|
{
|
||||||
|
correlationId = envelope.WorkerCommandReply.Reply?.CorrelationId ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_pendingCommands.TryRemove(correlationId, out PendingCommand? pendingCommand))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Ignoring late or unknown worker command reply for session {SessionId} and correlation {CorrelationId}.",
|
||||||
|
SessionId,
|
||||||
|
correlationId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||||||
|
_metrics?.CommandSucceeded(pendingCommand.Method, duration);
|
||||||
|
pendingCommand.SetResult(envelope.WorkerCommandReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemovePendingCommandAsFailed(
|
||||||
|
string correlationId,
|
||||||
|
PendingCommand pendingCommand,
|
||||||
|
WorkerClientErrorCode errorCode,
|
||||||
|
string message)
|
||||||
|
{
|
||||||
|
if (!_pendingCommands.TryRemove(correlationId, out _))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||||||
|
_metrics?.CommandFailed(pendingCommand.Method, errorCode.ToString(), duration);
|
||||||
|
pendingCommand.SetException(new WorkerClientException(errorCode, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<WorkerEnvelope> ReadHandshakeEnvelopeAsync(
|
||||||
|
WorkerEnvelope.BodyOneofCase expectedBody,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (envelope.BodyCase != expectedBody)
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
$"Worker handshake expected {expectedBody} but received {envelope.BodyCase}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateWorkerHello(WorkerHello workerHello)
|
||||||
|
{
|
||||||
|
if (workerHello.ProtocolVersion != _connection.FrameOptions.ProtocolVersion)
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
"Worker hello protocol version does not match the gateway protocol version.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(workerHello.Nonce, _connection.Nonce, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.ProtocolViolation,
|
||||||
|
"Worker hello nonce does not match the gateway nonce.");
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_processId = workerHello.WorkerProcessId == 0
|
||||||
|
? _connection.ProcessHandle?.ProcessId
|
||||||
|
: workerHello.WorkerProcessId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkReady(WorkerReady ready)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_processId = ready.WorkerProcessId == 0
|
||||||
|
? _processId ?? _connection.ProcessHandle?.ProcessId
|
||||||
|
: ready.WorkerProcessId;
|
||||||
|
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||||||
|
_state = WorkerClientState.Ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTimeOffset readyAt = _timeProvider.GetUtcNow();
|
||||||
|
DateTimeOffset launchedAt = _connection.ProcessHandle?.LaunchedAt ?? readyAt;
|
||||||
|
_metrics?.WorkerStarted(readyAt - launchedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkHeartbeat(WorkerHeartbeat heartbeat)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||||||
|
if (heartbeat.WorkerProcessId != 0)
|
||||||
|
{
|
||||||
|
_processId = heartbeat.WorkerProcessId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkClosing()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is WorkerClientState.Closed or WorkerClientState.Faulted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = WorkerClientState.Closing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void MarkClosed(string reason)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state == WorkerClientState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = WorkerClientState.Closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopCts.Cancel();
|
||||||
|
_outboundEnvelopes.Writer.TryComplete();
|
||||||
|
_events.Writer.TryComplete();
|
||||||
|
CompletePendingCommands(
|
||||||
|
new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.GatewayShutdown,
|
||||||
|
$"Worker client closed because {reason}."));
|
||||||
|
_metrics?.WorkerStopped(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetFaulted(
|
||||||
|
WorkerClientErrorCode errorCode,
|
||||||
|
string message,
|
||||||
|
Exception? exception)
|
||||||
|
{
|
||||||
|
WorkerClientException fault = exception is null
|
||||||
|
? new WorkerClientException(errorCode, message)
|
||||||
|
: new WorkerClientException(errorCode, message, exception);
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state is WorkerClientState.Faulted or WorkerClientState.Closed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopCts.Cancel();
|
||||||
|
_outboundEnvelopes.Writer.TryComplete(fault);
|
||||||
|
_events.Writer.TryComplete(fault);
|
||||||
|
CompletePendingCommands(fault);
|
||||||
|
_metrics?.Fault(errorCode.ToString());
|
||||||
|
_logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CompletePendingCommands(Exception exception)
|
||||||
|
{
|
||||||
|
foreach (KeyValuePair<string, PendingCommand> item in _pendingCommands.ToArray())
|
||||||
|
{
|
||||||
|
if (_pendingCommands.TryRemove(item.Key, out PendingCommand? pendingCommand))
|
||||||
|
{
|
||||||
|
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||||||
|
_metrics?.CommandFailed(pendingCommand.Method, exception.GetType().Name, duration);
|
||||||
|
pendingCommand.SetException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TransitionFromCreatedToHandshaking()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_state != WorkerClientState.Created)
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.InvalidState,
|
||||||
|
$"Worker client cannot start from state {_state}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = WorkerClientState.Handshaking;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureReady()
|
||||||
|
{
|
||||||
|
WorkerClientState state = State;
|
||||||
|
if (state != WorkerClientState.Ready)
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.InvalidState,
|
||||||
|
$"Worker client is not ready. Current state is {state}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsTerminalState()
|
||||||
|
{
|
||||||
|
WorkerClientState state = State;
|
||||||
|
return state is WorkerClientState.Closing or WorkerClientState.Closed or WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnqueueAsync(
|
||||||
|
WorkerEnvelope envelope,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _outboundEnvelopes.Writer.WriteAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException exception)
|
||||||
|
{
|
||||||
|
throw new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.WriteFailed,
|
||||||
|
"Worker outbound channel is closed.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateGatewayHelloEnvelope()
|
||||||
|
{
|
||||||
|
return CreateEnvelope(
|
||||||
|
correlationId: string.Empty,
|
||||||
|
envelope => envelope.GatewayHello = new GatewayHello
|
||||||
|
{
|
||||||
|
SupportedProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||||||
|
Nonce = _connection.Nonce,
|
||||||
|
GatewayVersion = typeof(GatewayContractInfo).Assembly.GetName().Version?.ToString() ?? GatewayVersionFallback,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateCommandEnvelope(
|
||||||
|
string correlationId,
|
||||||
|
WorkerCommand command)
|
||||||
|
{
|
||||||
|
return CreateEnvelope(
|
||||||
|
correlationId,
|
||||||
|
envelope => envelope.WorkerCommand = command.Clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateShutdownEnvelope(
|
||||||
|
TimeSpan timeout,
|
||||||
|
string reason)
|
||||||
|
{
|
||||||
|
return CreateEnvelope(
|
||||||
|
correlationId: string.Empty,
|
||||||
|
envelope => envelope.WorkerShutdown = new WorkerShutdown
|
||||||
|
{
|
||||||
|
GracePeriod = Duration.FromTimeSpan(timeout),
|
||||||
|
Reason = reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkerEnvelope CreateEnvelope(
|
||||||
|
string correlationId,
|
||||||
|
Action<WorkerEnvelope> setBody)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = new()
|
||||||
|
{
|
||||||
|
ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||||||
|
SessionId = SessionId,
|
||||||
|
Sequence = (ulong)Interlocked.Increment(ref _nextSequence),
|
||||||
|
CorrelationId = correlationId,
|
||||||
|
};
|
||||||
|
setBody(envelope);
|
||||||
|
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCommandMethod(WorkerCommand command)
|
||||||
|
{
|
||||||
|
return command.Command?.Kind.ToString() ?? MxCommandKind.Unspecified.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateWorkerFaultMessage(WorkerFault fault)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(fault.DiagnosticMessage)
|
||||||
|
? $"Worker faulted with category {fault.Category}."
|
||||||
|
: $"Worker faulted with category {fault.Category}: {fault.DiagnosticMessage}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WaitForBackgroundTasksAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Task[] tasks = new[] { _readLoopTask, _writeLoopTask, _heartbeatLoopTask }
|
||||||
|
.Where(task => task is not null)
|
||||||
|
.Cast<Task>()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (tasks.Length == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class PendingCommand
|
||||||
|
{
|
||||||
|
private readonly TaskCompletionSource<WorkerCommandReply> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
public PendingCommand(
|
||||||
|
string correlationId,
|
||||||
|
string method,
|
||||||
|
long startTimestamp)
|
||||||
|
{
|
||||||
|
CorrelationId = correlationId;
|
||||||
|
Method = method;
|
||||||
|
StartTimestamp = startTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CorrelationId { get; }
|
||||||
|
|
||||||
|
public string Method { get; }
|
||||||
|
|
||||||
|
public long StartTimestamp { get; }
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> Task => _completion.Task;
|
||||||
|
|
||||||
|
public void SetResult(WorkerCommandReply reply)
|
||||||
|
{
|
||||||
|
_completion.TrySetResult(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetException(Exception exception)
|
||||||
|
{
|
||||||
|
_completion.TrySetException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public sealed class WorkerClientConnection
|
||||||
|
{
|
||||||
|
public WorkerClientConnection(
|
||||||
|
string sessionId,
|
||||||
|
string nonce,
|
||||||
|
Stream stream,
|
||||||
|
WorkerFrameProtocolOptions frameOptions,
|
||||||
|
WorkerProcessHandle? processHandle = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Session id is required.", nameof(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(nonce))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Worker nonce is required.", nameof(nonce));
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionId = sessionId;
|
||||||
|
Nonce = nonce;
|
||||||
|
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||||
|
FrameOptions = frameOptions ?? throw new ArgumentNullException(nameof(frameOptions));
|
||||||
|
ProcessHandle = processHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SessionId { get; }
|
||||||
|
|
||||||
|
public string Nonce { get; }
|
||||||
|
|
||||||
|
public Stream Stream { get; }
|
||||||
|
|
||||||
|
public WorkerFrameProtocolOptions FrameOptions { get; }
|
||||||
|
|
||||||
|
public WorkerProcessHandle? ProcessHandle { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public enum WorkerClientErrorCode
|
||||||
|
{
|
||||||
|
InvalidState,
|
||||||
|
ProtocolViolation,
|
||||||
|
PipeDisconnected,
|
||||||
|
CommandTimeout,
|
||||||
|
WorkerFaulted,
|
||||||
|
HeartbeatExpired,
|
||||||
|
ShutdownTimeout,
|
||||||
|
GatewayShutdown,
|
||||||
|
WriteFailed,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public sealed class WorkerClientException : Exception
|
||||||
|
{
|
||||||
|
public WorkerClientException(
|
||||||
|
WorkerClientErrorCode errorCode,
|
||||||
|
string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerClientException(
|
||||||
|
WorkerClientErrorCode errorCode,
|
||||||
|
string message,
|
||||||
|
Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
ErrorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkerClientErrorCode ErrorCode { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public sealed class WorkerClientOptions
|
||||||
|
{
|
||||||
|
public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15);
|
||||||
|
public static readonly TimeSpan DefaultHeartbeatCheckInterval = TimeSpan.FromSeconds(1);
|
||||||
|
public static readonly TimeSpan DefaultEventChannelFullModeTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
public WorkerClientOptions()
|
||||||
|
{
|
||||||
|
HeartbeatGrace = DefaultHeartbeatGrace;
|
||||||
|
HeartbeatCheckInterval = DefaultHeartbeatCheckInterval;
|
||||||
|
EventChannelCapacity = 1_024;
|
||||||
|
EventChannelFullModeTimeout = DefaultEventChannelFullModeTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TimeSpan HeartbeatGrace { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan HeartbeatCheckInterval { get; init; }
|
||||||
|
|
||||||
|
public int EventChannelCapacity { get; init; }
|
||||||
|
|
||||||
|
public TimeSpan EventChannelFullModeTimeout { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
public enum WorkerClientState
|
||||||
|
{
|
||||||
|
Created,
|
||||||
|
Handshaking,
|
||||||
|
Ready,
|
||||||
|
Closing,
|
||||||
|
Closed,
|
||||||
|
Faulted,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"defaultProvider": "cdnjs",
|
||||||
|
"libraries": [
|
||||||
|
{
|
||||||
|
"library": "bootstrap@5.3.3",
|
||||||
|
"destination": "wwwroot/lib/bootstrap/",
|
||||||
|
"files": [
|
||||||
|
"css/bootstrap.min.css",
|
||||||
|
"js/bootstrap.bundle.min.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
:root {
|
||||||
|
--mxgw-surface: #f7f8fa;
|
||||||
|
--mxgw-border: #d8dee6;
|
||||||
|
--mxgw-ink-muted: #667085;
|
||||||
|
--mxgw-accent: #146c64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-body {
|
||||||
|
background: var(--mxgw-surface);
|
||||||
|
color: #1f2933;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-navbar {
|
||||||
|
min-height: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page-header h1,
|
||||||
|
.section-heading h2 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid var(--mxgw-border);
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: .75rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid.compact {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card {
|
||||||
|
border-color: var(--mxgw-border);
|
||||||
|
border-radius: .375rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--mxgw-ink-muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
color: var(--mxgw-accent);
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.25;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-detail {
|
||||||
|
color: var(--mxgw-ink-muted);
|
||||||
|
font-size: .85rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table {
|
||||||
|
--bs-table-bg: #fff;
|
||||||
|
border-color: var(--mxgw-border);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table th {
|
||||||
|
color: #344054;
|
||||||
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-table td {
|
||||||
|
max-width: 24rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table th {
|
||||||
|
width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--mxgw-border);
|
||||||
|
border-radius: .375rem;
|
||||||
|
color: var(--mxgw-ink-muted);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-login {
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
border-color: var(--mxgw-border);
|
||||||
|
border-radius: .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.dashboard-content {
|
||||||
|
padding: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-page-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table th {
|
||||||
|
width: 9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Google.Protobuf;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Contracts;
|
||||||
|
|
||||||
|
public sealed class ClientProtoInputTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs()
|
||||||
|
{
|
||||||
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||||
|
string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");
|
||||||
|
|
||||||
|
using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
||||||
|
JsonElement root = manifest.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
||||||
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
|
||||||
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());
|
||||||
|
|
||||||
|
string protoRoot = Path.Combine(repositoryRoot.FullName, root.GetProperty("protoRoot").GetString()!);
|
||||||
|
foreach (JsonElement sourceFile in root.GetProperty("sourceFiles").EnumerateArray())
|
||||||
|
{
|
||||||
|
string sourcePath = Path.Combine(protoRoot, sourceFile.GetProperty("path").GetString()!);
|
||||||
|
Assert.True(File.Exists(sourcePath), $"Expected proto source file '{sourcePath}' to exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (JsonProperty output in root.GetProperty("generatedOutputs").EnumerateObject())
|
||||||
|
{
|
||||||
|
string outputPath = Path.Combine(repositoryRoot.FullName, output.Value.GetString()!);
|
||||||
|
Assert.True(Directory.Exists(outputPath), $"Expected generated output directory '{outputPath}' to exist.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OpenSessionReplyFixture_ParsesWithCurrentContract()
|
||||||
|
{
|
||||||
|
OpenSessionReply reply = ParseFixture(
|
||||||
|
"open-session-reply.ok.json",
|
||||||
|
OpenSessionReply.Parser);
|
||||||
|
|
||||||
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
||||||
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RegisterCommandRequestFixture_ParsesWithCurrentContract()
|
||||||
|
{
|
||||||
|
MxCommandRequest request = ParseFixture(
|
||||||
|
"register-command-request.json",
|
||||||
|
MxCommandRequest.Parser);
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.Register, request.Command.Kind);
|
||||||
|
Assert.Equal("fixture-client", request.Command.Register.ClientName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void OnDataChangeEventFixture_ParsesWithCurrentContract()
|
||||||
|
{
|
||||||
|
MxEvent gatewayEvent = ParseFixture(
|
||||||
|
"on-data-change-event.json",
|
||||||
|
MxEvent.Parser);
|
||||||
|
|
||||||
|
Assert.Equal(MxEventFamily.OnDataChange, gatewayEvent.Family);
|
||||||
|
Assert.Equal(1ul, gatewayEvent.WorkerSequence);
|
||||||
|
Assert.Equal(MxDataType.Integer, gatewayEvent.Value.DataType);
|
||||||
|
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T ParseFixture<T>(
|
||||||
|
string fixtureName,
|
||||||
|
MessageParser<T> parser)
|
||||||
|
where T : IMessage<T>
|
||||||
|
{
|
||||||
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
||||||
|
string fixturePath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "golden", fixtureName);
|
||||||
|
|
||||||
|
return parser.ParseJson(File.ReadAllText(fixturePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DirectoryInfo FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
while (current is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||||
|
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||||
|
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||||
|
{
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ public sealed class GatewayContractInfoTests
|
|||||||
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
|
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GatewayProtocolVersion_StartsAtVersionOne()
|
||||||
|
{
|
||||||
|
Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void WorkerProtocolVersion_StartsAtVersionOne()
|
public void WorkerProtocolVersion_StartsAtVersionOne()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthenticatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
|
||||||
|
{
|
||||||
|
FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin));
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(verifier);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Principal);
|
||||||
|
Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
||||||
|
Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value);
|
||||||
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
|
claim.Type == DashboardAuthenticationDefaults.ScopeClaimType
|
||||||
|
&& claim.Value == GatewayScopes.Admin);
|
||||||
|
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
|
||||||
|
SuccessWithScopes(GatewayScopes.EventsRead)));
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.Null(result.Principal);
|
||||||
|
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(
|
||||||
|
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
|
||||||
|
requireAdminScope: false);
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.NotNull(result.Principal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
|
||||||
|
{
|
||||||
|
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
|
||||||
|
ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)));
|
||||||
|
|
||||||
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
|
"mxgw_operator01_super-secret",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(
|
||||||
|
IApiKeyVerifier verifier,
|
||||||
|
bool requireAdminScope = true)
|
||||||
|
{
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
verifier,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
RequireAdminScope = requireAdminScope
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
|
||||||
|
{
|
||||||
|
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||||
|
{
|
||||||
|
public string? LastAuthorizationHeader { get; private set; }
|
||||||
|
|
||||||
|
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||||
|
string? authorizationHeader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastAuthorizationHeader = authorizationHeader;
|
||||||
|
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardAuthorizationHandlerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Loopback,
|
||||||
|
allowAnonymousLocalhost: true);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
CreatePrincipal(GatewayScopes.EventsRead),
|
||||||
|
IPAddress.Loopback,
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
CreatePrincipal(GatewayScopes.Admin),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false);
|
||||||
|
|
||||||
|
Assert.True(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||||
|
ClaimsPrincipal principal,
|
||||||
|
IPAddress remoteAddress,
|
||||||
|
bool allowAnonymousLocalhost)
|
||||||
|
{
|
||||||
|
DashboardAuthorizationRequirement requirement = new();
|
||||||
|
DefaultHttpContext httpContext = new();
|
||||||
|
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||||
|
DashboardAuthorizationHandler handler = new(
|
||||||
|
new HttpContextAccessor { HttpContext = httpContext },
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||||
|
RequireAdminScope = true
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
|
||||||
|
|
||||||
|
await handler.HandleAsync(context);
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal CreatePrincipal(string scope)
|
||||||
|
{
|
||||||
|
ClaimsIdentity identity = new(
|
||||||
|
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(identity);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Server;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardCookieOptionsTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Build_ConfiguresSecureDashboardCookie()
|
||||||
|
{
|
||||||
|
WebApplication app = GatewayApplication.Build([]);
|
||||||
|
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||||
|
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||||
|
|
||||||
|
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||||
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
|
||||||
|
Assert.True(options.Cookie.HttpOnly);
|
||||||
|
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||||
|
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||||
|
Assert.Equal("/", options.Cookie.Path);
|
||||||
|
Assert.Equal("/dashboard/login", options.LoginPath);
|
||||||
|
Assert.Equal("/dashboard/logout", options.LogoutPath);
|
||||||
|
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Dashboard;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Dashboard;
|
||||||
|
|
||||||
|
public sealed class DashboardSnapshotServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Empty(snapshot.Sessions);
|
||||||
|
Assert.Empty(snapshot.Workers);
|
||||||
|
Assert.Empty(snapshot.Faults);
|
||||||
|
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0);
|
||||||
|
Assert.Equal("Healthy", snapshot.GatewayStatus);
|
||||||
|
Assert.NotNull(snapshot.Configuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession activeSession = CreateSession(
|
||||||
|
"session-active",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
|
||||||
|
activeSession.MarkReady();
|
||||||
|
GatewaySession faultedSession = CreateSession(
|
||||||
|
"session-faulted",
|
||||||
|
"client-two",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
|
||||||
|
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
|
||||||
|
faultedSession.MarkFaulted("worker pipe disconnected");
|
||||||
|
registry.TryAdd(activeSession);
|
||||||
|
registry.TryAdd(faultedSession);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
metrics.SessionOpened();
|
||||||
|
metrics.SessionOpened();
|
||||||
|
metrics.CommandStarted("Register");
|
||||||
|
metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7));
|
||||||
|
metrics.EventReceived("session-active", "OnDataChange");
|
||||||
|
metrics.Fault("WorkerFaulted");
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal(2, snapshot.Sessions.Count);
|
||||||
|
Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId);
|
||||||
|
Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State);
|
||||||
|
Assert.Equal(2, snapshot.Workers.Count);
|
||||||
|
Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1);
|
||||||
|
Assert.Contains(
|
||||||
|
snapshot.Metrics,
|
||||||
|
metric => metric.Name == "mxgateway.events.received"
|
||||||
|
&& metric.Dimension == "OnDataChange"
|
||||||
|
&& metric.Value == 1);
|
||||||
|
DashboardFaultSummary fault = Assert.Single(snapshot.Faults);
|
||||||
|
Assert.Equal("Worker", fault.Source);
|
||||||
|
Assert.Equal("session-faulted", fault.SessionId);
|
||||||
|
Assert.Equal("worker pipe disconnected", fault.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession session = CreateSession(
|
||||||
|
"session-redacted",
|
||||||
|
"Bearer mxgw_admin_super-secret",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"),
|
||||||
|
clientSessionName: "password=hunter2",
|
||||||
|
clientCorrelationId: "token=abc123");
|
||||||
|
session.MarkFaulted("secret=credential-value");
|
||||||
|
registry.TryAdd(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
DashboardSessionSummary summary = Assert.Single(snapshot.Sessions);
|
||||||
|
Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity);
|
||||||
|
Assert.Equal("[redacted]", summary.ClientSessionName);
|
||||||
|
Assert.Equal("[redacted]", summary.ClientCorrelationId);
|
||||||
|
Assert.Equal("[redacted]", summary.LastFault);
|
||||||
|
Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message);
|
||||||
|
Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_DoesNotMutateSessionOrWorkerState()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession session = CreateSession(
|
||||||
|
"session-active",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
registry.TryAdd(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(registry, metrics);
|
||||||
|
|
||||||
|
service.GetSnapshot();
|
||||||
|
service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal(1, registry.ActiveCount);
|
||||||
|
Assert.Equal(SessionState.Ready, session.State);
|
||||||
|
Assert.Equal(WorkerClientState.Ready, workerClient.State);
|
||||||
|
Assert.Equal(0, workerClient.StartCount);
|
||||||
|
Assert.Equal(0, workerClient.ShutdownCount);
|
||||||
|
Assert.Equal(0, workerClient.KillCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_AppliesRecentSessionAndFaultLimits()
|
||||||
|
{
|
||||||
|
SessionRegistry registry = new();
|
||||||
|
GatewaySession olderSession = CreateSession(
|
||||||
|
"session-older",
|
||||||
|
"client-one",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||||
|
GatewaySession newerSession = CreateSession(
|
||||||
|
"session-newer",
|
||||||
|
"client-two",
|
||||||
|
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
|
||||||
|
olderSession.MarkFaulted("older fault");
|
||||||
|
newerSession.MarkFaulted("newer fault");
|
||||||
|
registry.TryAdd(olderSession);
|
||||||
|
registry.TryAdd(newerSession);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
registry,
|
||||||
|
metrics,
|
||||||
|
new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
RecentSessionLimit = 1,
|
||||||
|
RecentFaultLimit = 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId);
|
||||||
|
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
new SessionRegistry(),
|
||||||
|
metrics,
|
||||||
|
new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
using CancellationTokenSource cancellation = new();
|
||||||
|
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||||
|
.WatchSnapshotsAsync(cancellation.Token)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
|
||||||
|
await cancellation.CancelAsync();
|
||||||
|
bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
Assert.False(hasNext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardSnapshotService CreateService(
|
||||||
|
SessionRegistry registry,
|
||||||
|
GatewayMetrics metrics,
|
||||||
|
GatewayOptions? options = null)
|
||||||
|
{
|
||||||
|
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions));
|
||||||
|
|
||||||
|
return new DashboardSnapshotService(
|
||||||
|
registry,
|
||||||
|
metrics,
|
||||||
|
configurationProvider,
|
||||||
|
Options.Create(resolvedOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateSession(
|
||||||
|
string sessionId,
|
||||||
|
string? clientIdentity,
|
||||||
|
DateTimeOffset openedAt,
|
||||||
|
string? clientSessionName = "test-session",
|
||||||
|
string? clientCorrelationId = "client-correlation")
|
||||||
|
{
|
||||||
|
return new GatewaySession(
|
||||||
|
sessionId,
|
||||||
|
"mxaccess",
|
||||||
|
$"mxaccess-gateway-1-{sessionId}",
|
||||||
|
"nonce",
|
||||||
|
clientIdentity,
|
||||||
|
clientSessionName,
|
||||||
|
clientCorrelationId,
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
TimeSpan.FromSeconds(5),
|
||||||
|
openedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient(
|
||||||
|
string sessionId,
|
||||||
|
int? processId,
|
||||||
|
WorkerClientState state) : IWorkerClient
|
||||||
|
{
|
||||||
|
public string SessionId { get; } = sessionId;
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = processId;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; private set; } = state;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
|
||||||
|
|
||||||
|
public int StartCount { get; private set; }
|
||||||
|
|
||||||
|
public int ShutdownCount { get; private set; }
|
||||||
|
|
||||||
|
public int KillCount { get; private set; }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
StartCount++;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ShutdownCount++;
|
||||||
|
State = WorkerClientState.Closed;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
KillCount++;
|
||||||
|
State = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,37 @@ public sealed class GatewayApplicationTests
|
|||||||
Assert.NotNull(metrics);
|
Assert.NotNull(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||||
|
{
|
||||||
|
WebApplication app = GatewayApplication.Build([]);
|
||||||
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||||
|
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/sessions");
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/workers");
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/events");
|
||||||
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/settings");
|
||||||
|
Assert.Contains(endpoints, endpoint =>
|
||||||
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogin");
|
||||||
|
Assert.Contains(endpoints, endpoint =>
|
||||||
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
||||||
|
{
|
||||||
|
WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
||||||
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(endpoints, endpoint =>
|
||||||
|
endpoint.RoutePattern.RawText?.StartsWith("/dashboard", StringComparison.Ordinal) == true);
|
||||||
|
Assert.DoesNotContain(endpoints, endpoint =>
|
||||||
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
|
||||||
|
"Dashboard",
|
||||||
|
StringComparison.Ordinal) == true);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(
|
[InlineData(
|
||||||
"MxGateway:Worker:ExecutablePath",
|
"MxGateway:Worker:ExecutablePath",
|
||||||
@@ -65,4 +96,12 @@ public sealed class GatewayApplicationTests
|
|||||||
exception.Failures,
|
exception.Failures,
|
||||||
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
|
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<RouteEndpoint> GetRouteEndpoints(WebApplication app)
|
||||||
|
{
|
||||||
|
return ((IEndpointRouteBuilder)app).DataSources
|
||||||
|
.SelectMany(dataSource => dataSource.Endpoints)
|
||||||
|
.OfType<RouteEndpoint>()
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
using MxGateway.Tests.Gateway.Workers.Fakes;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway;
|
||||||
|
|
||||||
|
public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
private const int ServerHandle = 1001;
|
||||||
|
private const int ItemHandle = 2002;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GatewayService_WithFakeWorker_CompletesSessionCommandEventAndClosePath()
|
||||||
|
{
|
||||||
|
ScriptedFakeWorkerProcessLauncher launcher = new();
|
||||||
|
await using GatewayServiceFixture fixture = new(launcher);
|
||||||
|
|
||||||
|
OpenSessionReply openReply = await fixture.Service.OpenSession(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = "fake-worker-e2e",
|
||||||
|
ClientCorrelationId = "open-correlation",
|
||||||
|
CommandTimeout = Duration.FromTimeSpan(TestTimeout),
|
||||||
|
},
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
RecordingServerStreamWriter<MxEvent> eventWriter = new();
|
||||||
|
Task streamTask = fixture.Service.StreamEvents(
|
||||||
|
new StreamEventsRequest { SessionId = openReply.SessionId },
|
||||||
|
eventWriter,
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
MxCommandReply registerReply = await fixture.Service.Invoke(
|
||||||
|
CreateRegisterRequest(openReply.SessionId),
|
||||||
|
new TestServerCallContext());
|
||||||
|
MxCommandReply addItemReply = await fixture.Service.Invoke(
|
||||||
|
CreateAddItemRequest(openReply.SessionId, registerReply.Register.ServerHandle),
|
||||||
|
new TestServerCallContext());
|
||||||
|
MxCommandReply adviseReply = await fixture.Service.Invoke(
|
||||||
|
CreateAdviseRequest(openReply.SessionId, registerReply.Register.ServerHandle, addItemReply.AddItem.ItemHandle),
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
MxEvent dataChange = await eventWriter.WaitForFirstMessageAsync(TestTimeout);
|
||||||
|
|
||||||
|
CloseSessionReply closeReply = await fixture.Service.CloseSession(
|
||||||
|
new CloseSessionRequest
|
||||||
|
{
|
||||||
|
SessionId = openReply.SessionId,
|
||||||
|
ClientCorrelationId = "close-correlation",
|
||||||
|
},
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
await streamTask.WaitAsync(TestTimeout);
|
||||||
|
await launcher.WorkerTask.WaitAsync(TestTimeout);
|
||||||
|
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(GatewayContractInfo.DefaultBackendName, openReply.BackendName);
|
||||||
|
Assert.Equal(ScriptedFakeWorkerProcessLauncher.ProcessId, openReply.WorkerProcessId);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(ServerHandle, registerReply.Register.ServerHandle);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, addItemReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(ItemHandle, addItemReply.AddItem.ItemHandle);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, adviseReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(MxEventFamily.OnDataChange, dataChange.Family);
|
||||||
|
Assert.Equal(openReply.SessionId, dataChange.SessionId);
|
||||||
|
Assert.Equal(ServerHandle, dataChange.ServerHandle);
|
||||||
|
Assert.Equal(ItemHandle, dataChange.ItemHandle);
|
||||||
|
Assert.Equal("scripted-value", dataChange.Value.StringValue);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code);
|
||||||
|
Assert.Equal(SessionState.Closed, closeReply.FinalState);
|
||||||
|
Assert.True(launcher.Process.HasExited);
|
||||||
|
Assert.Equal(
|
||||||
|
[MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise],
|
||||||
|
launcher.CommandKinds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateRegisterRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "register-correlation",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Register,
|
||||||
|
Register = new RegisterCommand { ClientName = "fake-worker-e2e-client" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateAddItemRequest(
|
||||||
|
string sessionId,
|
||||||
|
int serverHandle)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "add-item-correlation",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemDefinition = "Galaxy.Tag.Value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreateAdviseRequest(
|
||||||
|
string sessionId,
|
||||||
|
int serverHandle,
|
||||||
|
int itemHandle)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
ClientCorrelationId = "advise-correlation",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Advise,
|
||||||
|
Advise = new AdviseCommand
|
||||||
|
{
|
||||||
|
ServerHandle = serverHandle,
|
||||||
|
ItemHandle = itemHandle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class GatewayServiceFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly GatewayMetrics _metrics = new();
|
||||||
|
private readonly SessionRegistry _registry = new();
|
||||||
|
|
||||||
|
public GatewayServiceFixture(IWorkerProcessLauncher launcher)
|
||||||
|
{
|
||||||
|
IOptions<GatewayOptions> options = Options.Create(CreateOptions());
|
||||||
|
SessionWorkerClientFactory workerClientFactory = new(
|
||||||
|
launcher,
|
||||||
|
options,
|
||||||
|
_metrics,
|
||||||
|
NullLoggerFactory.Instance);
|
||||||
|
SessionManager sessionManager = new(
|
||||||
|
_registry,
|
||||||
|
workerClientFactory,
|
||||||
|
options,
|
||||||
|
_metrics,
|
||||||
|
logger: NullLogger<SessionManager>.Instance);
|
||||||
|
MxAccessGrpcMapper mapper = new();
|
||||||
|
EventStreamService eventStreamService = new(
|
||||||
|
sessionManager,
|
||||||
|
options,
|
||||||
|
mapper,
|
||||||
|
_metrics,
|
||||||
|
NullLogger<EventStreamService>.Instance);
|
||||||
|
|
||||||
|
Service = new MxAccessGatewayService(
|
||||||
|
sessionManager,
|
||||||
|
new GatewayRequestIdentityAccessor(),
|
||||||
|
new MxAccessGrpcRequestValidator(),
|
||||||
|
mapper,
|
||||||
|
eventStreamService,
|
||||||
|
NullLogger<MxAccessGatewayService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MxAccessGatewayService Service { get; }
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
|
{
|
||||||
|
await session.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
_metrics.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewayOptions CreateOptions()
|
||||||
|
{
|
||||||
|
return new GatewayOptions
|
||||||
|
{
|
||||||
|
Worker = new WorkerOptions
|
||||||
|
{
|
||||||
|
StartupTimeoutSeconds = 5,
|
||||||
|
ShutdownTimeoutSeconds = 5,
|
||||||
|
HeartbeatIntervalSeconds = 30,
|
||||||
|
HeartbeatGraceSeconds = 30,
|
||||||
|
MaxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
|
||||||
|
},
|
||||||
|
Sessions = new SessionOptions
|
||||||
|
{
|
||||||
|
DefaultCommandTimeoutSeconds = 5,
|
||||||
|
MaxSessions = 4,
|
||||||
|
},
|
||||||
|
Events = new EventOptions
|
||||||
|
{
|
||||||
|
QueueCapacity = 16,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ScriptedFakeWorkerProcessLauncher : IWorkerProcessLauncher
|
||||||
|
{
|
||||||
|
public const int ProcessId = 4680;
|
||||||
|
private readonly ConcurrentQueue<MxCommandKind> _commandKinds = new();
|
||||||
|
|
||||||
|
public FakeWorkerProcess Process { get; } = new(ProcessId);
|
||||||
|
|
||||||
|
public IReadOnlyCollection<MxCommandKind> CommandKinds => _commandKinds.ToArray();
|
||||||
|
|
||||||
|
public Task WorkerTask { get; private set; } = Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<WorkerProcessHandle> LaunchAsync(
|
||||||
|
WorkerProcessLaunchRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
WorkerTask = RunWorkerAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
return Task.FromResult(new WorkerProcessHandle(
|
||||||
|
Process,
|
||||||
|
new WorkerProcessCommandLine("fake-worker.exe", []),
|
||||||
|
DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunWorkerAsync(
|
||||||
|
WorkerProcessLaunchRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using FakeWorkerHarness harness = await FakeWorkerHarness.ConnectToGatewayPipeAsync(
|
||||||
|
request.SessionId,
|
||||||
|
request.Nonce,
|
||||||
|
request.PipeName,
|
||||||
|
request.ProtocolVersion,
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
await harness.CompleteStartupAsync(ProcessId, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
WorkerEnvelope envelope = await harness.ReadGatewayEnvelopeAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand)
|
||||||
|
{
|
||||||
|
await ReplyToCommandAsync(harness, envelope, cancellationToken).ConfigureAwait(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
|
||||||
|
{
|
||||||
|
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
Process.MarkExited(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException($"Unexpected gateway envelope {envelope.BodyCase}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReplyToCommandAsync(
|
||||||
|
FakeWorkerHarness harness,
|
||||||
|
WorkerEnvelope commandEnvelope,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
MxCommand command = commandEnvelope.WorkerCommand.Command;
|
||||||
|
_commandKinds.Enqueue(command.Kind);
|
||||||
|
|
||||||
|
await harness.ReplyToCommandAsync(
|
||||||
|
commandEnvelope,
|
||||||
|
configureReply: reply => ConfigureReply(reply, command.Kind),
|
||||||
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (command.Kind == MxCommandKind.Advise)
|
||||||
|
{
|
||||||
|
await harness.EmitEventAsync(
|
||||||
|
MxEventFamily.OnDataChange,
|
||||||
|
cancellationToken,
|
||||||
|
mxEvent =>
|
||||||
|
{
|
||||||
|
mxEvent.ServerHandle = command.Advise.ServerHandle;
|
||||||
|
mxEvent.ItemHandle = command.Advise.ItemHandle;
|
||||||
|
mxEvent.Quality = 192;
|
||||||
|
mxEvent.Value = new MxValue
|
||||||
|
{
|
||||||
|
DataType = MxDataType.String,
|
||||||
|
StringValue = "scripted-value",
|
||||||
|
};
|
||||||
|
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||||
|
}).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ConfigureReply(
|
||||||
|
MxCommandReply reply,
|
||||||
|
MxCommandKind kind)
|
||||||
|
{
|
||||||
|
switch (kind)
|
||||||
|
{
|
||||||
|
case MxCommandKind.Register:
|
||||||
|
reply.Register = new RegisterReply { ServerHandle = ServerHandle };
|
||||||
|
break;
|
||||||
|
case MxCommandKind.AddItem:
|
||||||
|
reply.AddItem = new AddItemReply { ItemHandle = ItemHandle };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
|
||||||
|
{
|
||||||
|
public int Id { get; } = processId;
|
||||||
|
|
||||||
|
public bool HasExited { get; private set; }
|
||||||
|
|
||||||
|
public int? ExitCode { get; private set; }
|
||||||
|
|
||||||
|
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
HasExited = true;
|
||||||
|
ExitCode ??= 0;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(bool entireProcessTree)
|
||||||
|
{
|
||||||
|
MarkExited(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkExited(int exitCode)
|
||||||
|
{
|
||||||
|
HasExited = true;
|
||||||
|
ExitCode = exitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingServerStreamWriter<T> : IServerStreamWriter<T>
|
||||||
|
{
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
private readonly TaskCompletionSource<T> _firstMessage = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private readonly List<T> _messages = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<T> Messages
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _messages.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public WriteOptions? WriteOptions { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(T message)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
_messages.Add(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_firstMessage.TrySetResult(message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> WaitForFirstMessageAsync(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
return await _firstMessage.Task.WaitAsync(timeout).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||||
|
{
|
||||||
|
private readonly Metadata _requestHeaders = [];
|
||||||
|
private readonly Metadata _responseTrailers = [];
|
||||||
|
private readonly Dictionary<object, object> _userState = [];
|
||||||
|
private Status _status;
|
||||||
|
private WriteOptions? _writeOptions;
|
||||||
|
|
||||||
|
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||||
|
|
||||||
|
protected override string HostCore => "localhost";
|
||||||
|
|
||||||
|
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||||
|
|
||||||
|
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||||
|
|
||||||
|
protected override Metadata RequestHeadersCore => _requestHeaders;
|
||||||
|
|
||||||
|
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||||
|
|
||||||
|
protected override Metadata ResponseTrailersCore => _responseTrailers;
|
||||||
|
|
||||||
|
protected override Status StatusCore
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set => _status = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override WriteOptions? WriteOptionsCore
|
||||||
|
{
|
||||||
|
get => _writeOptions;
|
||||||
|
set => _writeOptions = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override AuthContext AuthContextCore { get; } = new(
|
||||||
|
string.Empty,
|
||||||
|
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
protected override IDictionary<object, object> UserStateCore => _userState;
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||||
|
ContextPropagationOptions? options)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Configuration;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
using MxGateway.Server.Metrics;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Grpc;
|
||||||
|
|
||||||
|
public sealed class EventStreamServiceTests
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_YieldsEventsInWorkerOrder()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
FakeSessionManager sessionManager = new(session);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(sessionManager, metrics: metrics);
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 11, MxEventFamily.OnWriteComplete));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
|
||||||
|
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
|
||||||
|
|
||||||
|
Assert.Equal([10UL, 11UL], events.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
|
||||||
|
Assert.Equal(MxEventFamily.OnDataChange, events[0].Family);
|
||||||
|
Assert.Equal(MxEventFamily.OnWriteComplete, events[1].Family);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().StreamDisconnects);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenSecondSubscriberStarts_RejectsClearly()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
using CancellationTokenSource firstSubscriberCancellation = new();
|
||||||
|
await using IAsyncEnumerator<MxEvent> firstSubscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), firstSubscriberCancellation.Token)
|
||||||
|
.GetAsyncEnumerator(firstSubscriberCancellation.Token);
|
||||||
|
Task<bool> firstMoveTask = firstSubscriber.MoveNextAsync().AsTask();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
|
||||||
|
await using IAsyncEnumerator<MxEvent> secondSubscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await secondSubscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.EventSubscriberAlreadyActive, exception.ErrorCode);
|
||||||
|
await firstSubscriberCancellation.CancelAsync();
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
async () => await firstMoveTask.WaitAsync(TestTimeout));
|
||||||
|
await firstSubscriber.DisposeAsync();
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenCanceled_DetachesSubscriber()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new();
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), cancellationTokenSource.Token)
|
||||||
|
.GetAsyncEnumerator(cancellationTokenSource.Token);
|
||||||
|
Task<bool> moveTask = subscriber.MoveNextAsync().AsTask();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 1);
|
||||||
|
await cancellationTokenSource.CancelAsync();
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
async () => await moveTask.WaitAsync(TestTimeout));
|
||||||
|
await subscriber.DisposeAsync();
|
||||||
|
|
||||||
|
await WaitUntilAsync(() => session.ActiveEventSubscriberCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenStreamQueueOverflows_FaultsSessionAndReportsOverflow()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(
|
||||||
|
new FakeSessionManager(session),
|
||||||
|
metrics,
|
||||||
|
queueCapacity: 1);
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 1, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 2, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 3, MxEventFamily.OnDataChange));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
Assert.True(await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
await WaitUntilAsync(() => session.State == SessionState.Faulted);
|
||||||
|
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(
|
||||||
|
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, exception.ErrorCode);
|
||||||
|
Assert.Equal(SessionState.Faulted, session.State);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().QueueOverflows);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().Faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_DoesNotSynthesizeOperationComplete()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new();
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session));
|
||||||
|
workerClient.Events.Add(CreateWorkerEvent(sequence: 10, MxEventFamily.OnWriteComplete));
|
||||||
|
workerClient.CompleteAfterConfiguredEvents = true;
|
||||||
|
|
||||||
|
List<MxEvent> events = await CollectEventsAsync(service, session.SessionId);
|
||||||
|
|
||||||
|
MxEvent mxEvent = Assert.Single(events);
|
||||||
|
Assert.Equal(MxEventFamily.OnWriteComplete, mxEvent.Family);
|
||||||
|
Assert.DoesNotContain(events, candidate => candidate.Family == MxEventFamily.OperationComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEventsAsync_WhenWorkerEventStreamFaults_PropagatesTerminalFault()
|
||||||
|
{
|
||||||
|
FakeWorkerClient workerClient = new()
|
||||||
|
{
|
||||||
|
TerminalException = new WorkerClientException(
|
||||||
|
WorkerClientErrorCode.WorkerFaulted,
|
||||||
|
"worker terminal fault"),
|
||||||
|
};
|
||||||
|
GatewaySession session = CreateReadySession(workerClient);
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
EventStreamService service = CreateService(new FakeSessionManager(session), metrics);
|
||||||
|
await using IAsyncEnumerator<MxEvent> subscriber = service
|
||||||
|
.StreamEventsAsync(CreateRequest(session.SessionId), CancellationToken.None)
|
||||||
|
.GetAsyncEnumerator();
|
||||||
|
|
||||||
|
WorkerClientException exception = await Assert.ThrowsAsync<WorkerClientException>(
|
||||||
|
async () => await subscriber.MoveNextAsync().AsTask().WaitAsync(TestTimeout));
|
||||||
|
|
||||||
|
Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode);
|
||||||
|
Assert.Equal(SessionState.Faulted, session.State);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().Faults);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EventStreamService CreateService(
|
||||||
|
FakeSessionManager sessionManager,
|
||||||
|
GatewayMetrics? metrics = null,
|
||||||
|
int queueCapacity = 8)
|
||||||
|
{
|
||||||
|
return new EventStreamService(
|
||||||
|
sessionManager,
|
||||||
|
Options.Create(new GatewayOptions
|
||||||
|
{
|
||||||
|
Events = new EventOptions
|
||||||
|
{
|
||||||
|
QueueCapacity = queueCapacity,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new MxAccessGrpcMapper(),
|
||||||
|
metrics ?? new GatewayMetrics(),
|
||||||
|
NullLogger<EventStreamService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<MxEvent>> CollectEventsAsync(
|
||||||
|
EventStreamService service,
|
||||||
|
string sessionId)
|
||||||
|
{
|
||||||
|
List<MxEvent> events = [];
|
||||||
|
await foreach (MxEvent mxEvent in service
|
||||||
|
.StreamEventsAsync(CreateRequest(sessionId), CancellationToken.None)
|
||||||
|
.WithCancellation(CancellationToken.None))
|
||||||
|
{
|
||||||
|
events.Add(mxEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StreamEventsRequest CreateRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateReadySession(FakeWorkerClient workerClient)
|
||||||
|
{
|
||||||
|
GatewaySession session = new(
|
||||||
|
"session-events",
|
||||||
|
GatewayContractInfo.DefaultBackendName,
|
||||||
|
"pipe",
|
||||||
|
"nonce",
|
||||||
|
"client",
|
||||||
|
"client-session",
|
||||||
|
"client-correlation",
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
session.AttachWorkerClient(workerClient);
|
||||||
|
session.MarkReady();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerEvent CreateWorkerEvent(
|
||||||
|
ulong sequence,
|
||||||
|
MxEventFamily family)
|
||||||
|
{
|
||||||
|
MxEvent mxEvent = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-events",
|
||||||
|
Family = family,
|
||||||
|
WorkerSequence = sequence,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (family)
|
||||||
|
{
|
||||||
|
case MxEventFamily.OnDataChange:
|
||||||
|
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnWriteComplete:
|
||||||
|
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OperationComplete:
|
||||||
|
mxEvent.OperationComplete = new OperationCompleteEvent();
|
||||||
|
break;
|
||||||
|
case MxEventFamily.OnBufferedDataChange:
|
||||||
|
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkerEvent
|
||||||
|
{
|
||||||
|
Event = mxEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||||
|
{
|
||||||
|
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
|
||||||
|
while (!predicate())
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionManager(GatewaySession session) : ISessionManager
|
||||||
|
{
|
||||||
|
public Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession gatewaySession)
|
||||||
|
{
|
||||||
|
gatewaySession = session;
|
||||||
|
return string.Equals(sessionId, session.SessionId, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return session.ReadEventsAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient : IWorkerClient
|
||||||
|
{
|
||||||
|
public List<WorkerEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
public bool CompleteAfterConfiguredEvents { get; set; }
|
||||||
|
|
||||||
|
public Exception? TerminalException { get; init; }
|
||||||
|
|
||||||
|
public string SessionId { get; } = "session-events";
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = 4321;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (WorkerEvent workerEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TerminalException is not null)
|
||||||
|
{
|
||||||
|
throw TerminalException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CompleteAfterConfiguredEvents)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
State = WorkerClientState.Closed;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
State = WorkerClientState.Faulted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using MxGateway.Contracts;
|
||||||
|
using MxGateway.Contracts.Proto;
|
||||||
|
using MxGateway.Server.Grpc;
|
||||||
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
|
using MxGateway.Server.Sessions;
|
||||||
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
|
namespace MxGateway.Tests.Gateway.Grpc;
|
||||||
|
|
||||||
|
public sealed class MxAccessGatewayServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenSession_WithValidRequest_ReturnsSessionDetails()
|
||||||
|
{
|
||||||
|
GatewayRequestIdentityAccessor identityAccessor = new();
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
OpenSessionResult = CreateSession("session-1", processId: 4321),
|
||||||
|
};
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager, identityAccessor);
|
||||||
|
|
||||||
|
using IDisposable identityScope = identityAccessor.Push(CreateIdentity());
|
||||||
|
OpenSessionReply reply = await service.OpenSession(
|
||||||
|
new OpenSessionRequest
|
||||||
|
{
|
||||||
|
ClientSessionName = "operator-session",
|
||||||
|
CommandTimeout = Duration.FromTimeSpan(TimeSpan.FromSeconds(7)),
|
||||||
|
},
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal("session-1", reply.SessionId);
|
||||||
|
Assert.Equal(GatewayContractInfo.DefaultBackendName, reply.BackendName);
|
||||||
|
Assert.Equal(4321, reply.WorkerProcessId);
|
||||||
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
||||||
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
||||||
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||||
|
Assert.Contains("unary-invoke", reply.Capabilities);
|
||||||
|
Assert.Equal("Operator Key", sessionManager.LastClientIdentity);
|
||||||
|
Assert.Equal("operator-session", sessionManager.LastOpenRequest?.ClientSessionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WhenSessionMissing_ThrowsNotFound()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
InvokeException = new SessionManagerException(
|
||||||
|
SessionManagerErrorCode.SessionNotFound,
|
||||||
|
"Session session-missing was not found."),
|
||||||
|
};
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.Invoke(
|
||||||
|
CreatePingRequest("session-missing"),
|
||||||
|
new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.NotFound, exception.StatusCode);
|
||||||
|
Assert.Contains("session-missing", exception.Status.Detail, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WithMismatchedPayload_ThrowsInvalidArgumentAndDoesNotCallSessionManager()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
Ping = new PingCommand { Message = "wrong-payload" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.Invoke(request, new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
|
Assert.Equal(0, sessionManager.InvokeCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invoke_WithWorkerReply_ReturnsHresultStatusAndMethodPayload()
|
||||||
|
{
|
||||||
|
const int hresult = unchecked((int)0x80004005);
|
||||||
|
FakeSessionManager sessionManager = new()
|
||||||
|
{
|
||||||
|
InvokeReply = new WorkerCommandReply
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
CorrelationId = "worker-correlation",
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
Hresult = hresult,
|
||||||
|
AddItem = new AddItemReply { ItemHandle = 42 },
|
||||||
|
DiagnosticMessage = "mxaccess diagnostic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
sessionManager.InvokeReply.Reply.Statuses.Add(new MxStatusProxy
|
||||||
|
{
|
||||||
|
Success = 0,
|
||||||
|
Category = MxStatusCategory.SoftwareError,
|
||||||
|
Detail = 1001,
|
||||||
|
DiagnosticText = "status detail",
|
||||||
|
});
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
MxCommandRequest request = new()
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
ClientCorrelationId = "client-correlation",
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem,
|
||||||
|
AddItem = new AddItemCommand
|
||||||
|
{
|
||||||
|
ServerHandle = 12,
|
||||||
|
ItemDefinition = "Galaxy.Tag.Value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
MxCommandReply reply = await service.Invoke(request, new TestServerCallContext());
|
||||||
|
|
||||||
|
Assert.Equal(MxCommandKind.AddItem, sessionManager.LastWorkerCommand?.Command.Kind);
|
||||||
|
Assert.Equal("Galaxy.Tag.Value", sessionManager.LastWorkerCommand?.Command.AddItem.ItemDefinition);
|
||||||
|
Assert.NotNull(sessionManager.LastWorkerCommand?.EnqueueTimestamp);
|
||||||
|
Assert.Equal(hresult, reply.Hresult);
|
||||||
|
Assert.Equal(42, reply.AddItem.ItemHandle);
|
||||||
|
Assert.Equal("status detail", Assert.Single(reply.Statuses).DiagnosticText);
|
||||||
|
Assert.Equal("mxaccess diagnostic", reply.DiagnosticMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StreamEvents_WithAfterSequence_WritesOnlyLaterEvents()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 1));
|
||||||
|
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
|
||||||
|
MxAccessGatewayService service = CreateService(sessionManager);
|
||||||
|
TestServerStreamWriter<MxEvent> writer = new();
|
||||||
|
|
||||||
|
await service.StreamEvents(
|
||||||
|
new StreamEventsRequest
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
AfterWorkerSequence = 1,
|
||||||
|
},
|
||||||
|
writer,
|
||||||
|
new TestServerCallContext());
|
||||||
|
|
||||||
|
MxEvent writtenEvent = Assert.Single(writer.Messages);
|
||||||
|
Assert.Equal((ulong)2, writtenEvent.WorkerSequence);
|
||||||
|
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
|
||||||
|
{
|
||||||
|
MxAccessGatewayService service = CreateService(new FakeSessionManager());
|
||||||
|
|
||||||
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
|
async () => await service.CloseSession(
|
||||||
|
new CloseSessionRequest(),
|
||||||
|
new TestServerCallContext()));
|
||||||
|
|
||||||
|
Assert.Equal(StatusCode.InvalidArgument, exception.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxAccessGatewayService CreateService(
|
||||||
|
FakeSessionManager sessionManager,
|
||||||
|
IGatewayRequestIdentityAccessor? identityAccessor = null)
|
||||||
|
{
|
||||||
|
return new MxAccessGatewayService(
|
||||||
|
sessionManager,
|
||||||
|
identityAccessor ?? new GatewayRequestIdentityAccessor(),
|
||||||
|
new MxAccessGrpcRequestValidator(),
|
||||||
|
new MxAccessGrpcMapper(),
|
||||||
|
new FakeEventStreamService(sessionManager),
|
||||||
|
NullLogger<MxAccessGatewayService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApiKeyIdentity CreateIdentity()
|
||||||
|
{
|
||||||
|
return new ApiKeyIdentity(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
DisplayName: "Operator Key",
|
||||||
|
Scopes: new HashSet<string>(StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GatewaySession CreateSession(
|
||||||
|
string sessionId,
|
||||||
|
int processId)
|
||||||
|
{
|
||||||
|
GatewaySession session = new(
|
||||||
|
sessionId,
|
||||||
|
GatewayContractInfo.DefaultBackendName,
|
||||||
|
"pipe",
|
||||||
|
"nonce",
|
||||||
|
"Operator Key",
|
||||||
|
"operator-session",
|
||||||
|
"client-correlation",
|
||||||
|
TimeSpan.FromSeconds(7),
|
||||||
|
TimeSpan.FromSeconds(30),
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
session.AttachWorkerClient(new FakeWorkerClient(processId));
|
||||||
|
session.MarkReady();
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MxCommandRequest CreatePingRequest(string sessionId)
|
||||||
|
{
|
||||||
|
return new MxCommandRequest
|
||||||
|
{
|
||||||
|
SessionId = sessionId,
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
Ping = new PingCommand { Message = "ping" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkerEvent CreateWorkerEvent(
|
||||||
|
string sessionId,
|
||||||
|
ulong workerSequence)
|
||||||
|
{
|
||||||
|
return new WorkerEvent
|
||||||
|
{
|
||||||
|
Event = new MxEvent
|
||||||
|
{
|
||||||
|
Family = MxEventFamily.OnDataChange,
|
||||||
|
SessionId = sessionId,
|
||||||
|
WorkerSequence = workerSequence,
|
||||||
|
OnDataChange = new OnDataChangeEvent(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionManager : ISessionManager
|
||||||
|
{
|
||||||
|
public GatewaySession? OpenSessionResult { get; init; }
|
||||||
|
|
||||||
|
public SessionOpenRequest? LastOpenRequest { get; private set; }
|
||||||
|
|
||||||
|
public string? LastClientIdentity { get; private set; }
|
||||||
|
|
||||||
|
public string? LastReadEventsSessionId { get; private set; }
|
||||||
|
|
||||||
|
public WorkerCommand? LastWorkerCommand { get; private set; }
|
||||||
|
|
||||||
|
public WorkerCommandReply InvokeReply { get; init; } = new()
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
Kind = MxCommandKind.Ping,
|
||||||
|
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
public Exception? InvokeException { get; init; }
|
||||||
|
|
||||||
|
public int InvokeCount { get; private set; }
|
||||||
|
|
||||||
|
public List<WorkerEvent> Events { get; } = [];
|
||||||
|
|
||||||
|
public void RecordReadEventsSessionId(string sessionId)
|
||||||
|
{
|
||||||
|
LastReadEventsSessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<GatewaySession> OpenSessionAsync(
|
||||||
|
SessionOpenRequest request,
|
||||||
|
string? clientIdentity,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastOpenRequest = request;
|
||||||
|
LastClientIdentity = clientIdentity;
|
||||||
|
|
||||||
|
return Task.FromResult(OpenSessionResult ?? CreateSession("session-1", processId: 1234));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetSession(
|
||||||
|
string sessionId,
|
||||||
|
out GatewaySession session)
|
||||||
|
{
|
||||||
|
session = OpenSessionResult ?? CreateSession(sessionId, processId: 1234);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
string sessionId,
|
||||||
|
WorkerCommand command,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
InvokeCount++;
|
||||||
|
LastWorkerCommand = command;
|
||||||
|
|
||||||
|
if (InvokeException is not null)
|
||||||
|
{
|
||||||
|
throw InvokeException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(InvokeReply);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
string sessionId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
LastReadEventsSessionId = sessionId;
|
||||||
|
foreach (WorkerEvent workerEvent in Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
yield return workerEvent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<SessionCloseResult> CloseSessionAsync(
|
||||||
|
string sessionId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<int> CloseExpiredLeasesAsync(
|
||||||
|
DateTimeOffset now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
|
||||||
|
{
|
||||||
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
|
StreamEventsRequest request,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
sessionManager.RecordReadEventsSessionId(request.SessionId);
|
||||||
|
foreach (WorkerEvent workerEvent in sessionManager.Events)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
await Task.Yield();
|
||||||
|
if (workerEvent.Event.WorkerSequence <= request.AfterWorkerSequence)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return workerEvent.Event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeWorkerClient(int processId) : IWorkerClient
|
||||||
|
{
|
||||||
|
public string SessionId { get; } = "session-1";
|
||||||
|
|
||||||
|
public int? ProcessId { get; } = processId;
|
||||||
|
|
||||||
|
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||||
|
|
||||||
|
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<WorkerCommandReply> InvokeAsync(
|
||||||
|
WorkerCommand command,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new WorkerCommandReply());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ShutdownAsync(
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Kill(string reason)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerStreamWriter<T> : IServerStreamWriter<T>
|
||||||
|
{
|
||||||
|
public List<T> Messages { get; } = [];
|
||||||
|
|
||||||
|
public WriteOptions? WriteOptions { get; set; }
|
||||||
|
|
||||||
|
public Task WriteAsync(T message)
|
||||||
|
{
|
||||||
|
Messages.Add(message);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestServerCallContext(CancellationToken cancellationToken = default) : ServerCallContext
|
||||||
|
{
|
||||||
|
private readonly Metadata requestHeaders = [];
|
||||||
|
private readonly Metadata responseTrailers = [];
|
||||||
|
private readonly Dictionary<object, object> userState = [];
|
||||||
|
private Status status;
|
||||||
|
private WriteOptions? writeOptions;
|
||||||
|
|
||||||
|
protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test";
|
||||||
|
|
||||||
|
protected override string HostCore => "localhost";
|
||||||
|
|
||||||
|
protected override string PeerCore => "ipv4:127.0.0.1:5000";
|
||||||
|
|
||||||
|
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
|
||||||
|
|
||||||
|
protected override Metadata RequestHeadersCore => requestHeaders;
|
||||||
|
|
||||||
|
protected override CancellationToken CancellationTokenCore => cancellationToken;
|
||||||
|
|
||||||
|
protected override Metadata ResponseTrailersCore => responseTrailers;
|
||||||
|
|
||||||
|
protected override Status StatusCore
|
||||||
|
{
|
||||||
|
get => status;
|
||||||
|
set => status = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override WriteOptions? WriteOptionsCore
|
||||||
|
{
|
||||||
|
get => writeOptions;
|
||||||
|
set => writeOptions = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override AuthContext AuthContextCore { get; } = new(
|
||||||
|
string.Empty,
|
||||||
|
new Dictionary<string, List<AuthProperty>>(StringComparer.Ordinal));
|
||||||
|
|
||||||
|
protected override IDictionary<object, object> UserStateCore => userState;
|
||||||
|
|
||||||
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders)
|
||||||
|
{
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override ContextPropagationToken CreatePropagationTokenCore(
|
||||||
|
ContextPropagationOptions? options)
|
||||||
|
{
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user