Merge origin/main with local pending work and update AGENTS.md references
- Resolve 14 conflicts from popping local stash on top of origin'seed1e88+8d3352fdoc-comment additions (11 mechanical, plus version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs) - Fix 4 test files that used AGENTS.md as the repo-root sentinel (now use CLAUDE.md, since AGENTS.md was removed in4731ab5) - Redirect 10 doc citations from AGENTS.md to the matching gateway.md sections (Value Model, Status Model, Security, STA Worker Thread Model, gRPC Layer rule, cancellation rule) Verified: solution build clean, x86 worker build clean, 266/266 gateway tests passing, 121/121 worker tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -824,9 +824,7 @@ public static class MxGatewayClientCli
|
||||
TextWriter output,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DiscoverHierarchyReply reply = await client.GalaxyDiscoverHierarchyAsync(
|
||||
new DiscoverHierarchyRequest(),
|
||||
cancellationToken)
|
||||
DiscoverHierarchyReply reply = await DiscoverAllGalaxyHierarchyAsync(client, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (arguments.HasFlag("json"))
|
||||
@@ -844,6 +842,39 @@ public static class MxGatewayClientCli
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<DiscoverHierarchyReply> DiscoverAllGalaxyHierarchyAsync(
|
||||
IMxGatewayCliClient client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DiscoverHierarchyReply aggregate = new();
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
string pageToken = string.Empty;
|
||||
do
|
||||
{
|
||||
DiscoverHierarchyReply page = await client.GalaxyDiscoverHierarchyAsync(
|
||||
new DiscoverHierarchyRequest
|
||||
{
|
||||
PageSize = 5000,
|
||||
PageToken = pageToken,
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
aggregate.Objects.Add(page.Objects);
|
||||
aggregate.TotalObjectCount = page.TotalObjectCount;
|
||||
pageToken = page.NextPageToken;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken)
|
||||
&& !seenPageTokens.Add(pageToken))
|
||||
{
|
||||
throw new MxGatewayException(
|
||||
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
|
||||
}
|
||||
}
|
||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
private static async Task<int> GalaxyWatchAsync(
|
||||
CliArguments arguments,
|
||||
IMxGatewayCliClient client,
|
||||
|
||||
@@ -48,6 +48,8 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
/// </summary>
|
||||
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
|
||||
|
||||
public Queue<DiscoverHierarchyReply> DiscoverHierarchyReplies { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue of exceptions to throw from TestConnection; dequeued in FIFO order.
|
||||
/// </summary>
|
||||
@@ -114,7 +116,10 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
||||
throw exception;
|
||||
}
|
||||
|
||||
return Task.FromResult(DiscoverHierarchyReply);
|
||||
return Task.FromResult(
|
||||
DiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
||||
? reply
|
||||
: DiscoverHierarchyReply);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -83,8 +83,10 @@ public sealed class GalaxyRepositoryClientTests
|
||||
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply
|
||||
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
NextPageToken = "page-2",
|
||||
TotalObjectCount = 2,
|
||||
Objects =
|
||||
{
|
||||
new GalaxyObject
|
||||
@@ -106,12 +108,29 @@ public sealed class GalaxyRepositoryClientTests
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
TotalObjectCount = 2,
|
||||
Objects =
|
||||
{
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 13,
|
||||
TagName = "DelmiaReceiver_002",
|
||||
},
|
||||
},
|
||||
});
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
|
||||
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
|
||||
|
||||
GalaxyObject obj = Assert.Single(objects);
|
||||
Assert.Equal(2, objects.Count);
|
||||
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
|
||||
Assert.Equal(5000, transport.DiscoverHierarchyCalls[0].Request.PageSize);
|
||||
Assert.Equal("", transport.DiscoverHierarchyCalls[0].Request.PageToken);
|
||||
Assert.Equal("page-2", transport.DiscoverHierarchyCalls[1].Request.PageToken);
|
||||
GalaxyObject obj = objects[0];
|
||||
Assert.Equal(12, obj.GobjectId);
|
||||
Assert.Equal("DelmiaReceiver_001", obj.TagName);
|
||||
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
|
||||
@@ -142,6 +161,57 @@ public sealed class GalaxyRepositoryClientTests
|
||||
/// <summary>
|
||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
NextPageToken = "7:1",
|
||||
});
|
||||
transport.DiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
NextPageToken = "7:1",
|
||||
});
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
|
||||
MxGatewayException exception = await Assert.ThrowsAsync<MxGatewayException>(
|
||||
async () => await client.DiscoverHierarchyAsync());
|
||||
|
||||
Assert.Contains("repeated page token", exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||
{
|
||||
FakeGalaxyRepositoryTransport transport = CreateTransport();
|
||||
await using GalaxyRepositoryClient client = CreateClient(transport);
|
||||
|
||||
await client.DiscoverHierarchyAsync(new DiscoverHierarchyOptions
|
||||
{
|
||||
RootContainedPath = "Area1/Line3",
|
||||
MaxDepth = 2,
|
||||
CategoryIds = [10, 13],
|
||||
TemplateChainContains = ["Pump"],
|
||||
TagNameGlob = "Pump_*",
|
||||
IncludeAttributes = false,
|
||||
AlarmBearingOnly = true,
|
||||
HistorizedOnly = true,
|
||||
});
|
||||
|
||||
DiscoverHierarchyRequest request = Assert.Single(transport.DiscoverHierarchyCalls).Request;
|
||||
Assert.Equal(DiscoverHierarchyRequest.RootOneofCase.RootContainedPath, request.RootCase);
|
||||
Assert.Equal("Area1/Line3", request.RootContainedPath);
|
||||
Assert.Equal(2, request.MaxDepth);
|
||||
Assert.Equal([10, 13], request.CategoryIds);
|
||||
Assert.Equal(["Pump"], request.TemplateChainContains);
|
||||
Assert.Equal("Pump_*", request.TagNameGlob);
|
||||
Assert.True(request.HasIncludeAttributes);
|
||||
Assert.False(request.IncludeAttributes);
|
||||
Assert.True(request.AlarmBearingOnly);
|
||||
Assert.True(request.HistorizedOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class MxGatewayClientCliTests
|
||||
var exitCode = MxGatewayClientCli.Run(["version"], output, error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("gateway-protocol=1", output.ToString());
|
||||
Assert.Contains("gateway-protocol=2", output.ToString());
|
||||
Assert.Contains("worker-protocol=1", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
@@ -33,7 +33,7 @@ public sealed class MxGatewayClientCliTests
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(["version", "--json"], output, error);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Contains("\"gatewayProtocolVersion\":1", output.ToString());
|
||||
Assert.Contains("\"gatewayProtocolVersion\":2", output.ToString());
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
|
||||
@@ -216,8 +216,10 @@ public sealed class MxGatewayClientCliTests
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
FakeCliClient fakeClient = new();
|
||||
fakeClient.GalaxyDiscoverHierarchyReply = new DiscoverHierarchyReply
|
||||
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
NextPageToken = "7:1",
|
||||
TotalObjectCount = 2,
|
||||
Objects =
|
||||
{
|
||||
new GalaxyObject
|
||||
@@ -236,7 +238,21 @@ public sealed class MxGatewayClientCliTests
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
fakeClient.GalaxyDiscoverHierarchyReplies.Enqueue(new DiscoverHierarchyReply
|
||||
{
|
||||
TotalObjectCount = 2,
|
||||
Objects =
|
||||
{
|
||||
new GalaxyObject
|
||||
{
|
||||
GobjectId = 8,
|
||||
TagName = "DelmiaReceiver_002",
|
||||
ContainedName = "DelmiaReceiver",
|
||||
ParentGobjectId = 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
@@ -251,10 +267,14 @@ public sealed class MxGatewayClientCliTests
|
||||
_ => fakeClient);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
Assert.Single(fakeClient.GalaxyDiscoverHierarchyRequests);
|
||||
Assert.Equal(2, fakeClient.GalaxyDiscoverHierarchyRequests.Count);
|
||||
Assert.Equal(5000, fakeClient.GalaxyDiscoverHierarchyRequests[0].PageSize);
|
||||
Assert.Equal("", fakeClient.GalaxyDiscoverHierarchyRequests[0].PageToken);
|
||||
Assert.Equal("7:1", fakeClient.GalaxyDiscoverHierarchyRequests[1].PageToken);
|
||||
string text = output.ToString();
|
||||
Assert.Contains("objects=1", text);
|
||||
Assert.Contains("objects=2", text);
|
||||
Assert.Contains("DelmiaReceiver_001", text);
|
||||
Assert.Contains("DelmiaReceiver_002", text);
|
||||
Assert.Contains("attributes=1", text);
|
||||
Assert.Equal(string.Empty, error.ToString());
|
||||
}
|
||||
@@ -436,6 +456,8 @@ public sealed class MxGatewayClientCliTests
|
||||
/// <summary>Galaxy discover hierarchy reply to return.</summary>
|
||||
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
|
||||
|
||||
public Queue<DiscoverHierarchyReply> GalaxyDiscoverHierarchyReplies { get; } = new();
|
||||
|
||||
/// <summary>List of received galaxy test connection requests.</summary>
|
||||
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
|
||||
|
||||
@@ -469,7 +491,10 @@ public sealed class MxGatewayClientCliTests
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
GalaxyDiscoverHierarchyRequests.Add(request);
|
||||
return Task.FromResult(GalaxyDiscoverHierarchyReply);
|
||||
return Task.FromResult(
|
||||
GalaxyDiscoverHierarchyReplies.TryDequeue(out DiscoverHierarchyReply? reply)
|
||||
? reply
|
||||
: GalaxyDiscoverHierarchyReply);
|
||||
}
|
||||
|
||||
/// <summary>List of received galaxy watch deploy events requests.</summary>
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace MxGateway.Client;
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
{
|
||||
private const int DiscoverHierarchyPageSize = 5000;
|
||||
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
|
||||
@@ -84,6 +86,8 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
{
|
||||
HttpHandler = handler,
|
||||
LoggerFactory = options.LoggerFactory,
|
||||
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
|
||||
MaxSendMessageSize = options.MaxGrpcMessageBytes,
|
||||
});
|
||||
|
||||
return new GalaxyRepositoryClient(
|
||||
@@ -175,12 +179,81 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
/// <returns>The collection of Galaxy objects in the hierarchy.</returns>
|
||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
|
||||
new DiscoverHierarchyRequest(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return await DiscoverHierarchyAsync(new DiscoverHierarchyOptions(), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return reply.Objects;
|
||||
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(
|
||||
DiscoverHierarchyOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
List<GalaxyObject> objects = [];
|
||||
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
|
||||
string pageToken = string.Empty;
|
||||
do
|
||||
{
|
||||
DiscoverHierarchyRequest request = CreateDiscoverHierarchyRequest(options);
|
||||
request.PageSize = DiscoverHierarchyPageSize;
|
||||
request.PageToken = pageToken;
|
||||
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
|
||||
request,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
objects.AddRange(reply.Objects);
|
||||
pageToken = reply.NextPageToken;
|
||||
if (!string.IsNullOrWhiteSpace(pageToken)
|
||||
&& !seenPageTokens.Add(pageToken))
|
||||
{
|
||||
throw new MxGatewayException(
|
||||
$"Galaxy DiscoverHierarchy returned a repeated page token '{pageToken}'.");
|
||||
}
|
||||
}
|
||||
while (!string.IsNullOrWhiteSpace(pageToken));
|
||||
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static DiscoverHierarchyRequest CreateDiscoverHierarchyRequest(DiscoverHierarchyOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
DiscoverHierarchyRequest request = new()
|
||||
{
|
||||
AlarmBearingOnly = options.AlarmBearingOnly,
|
||||
HistorizedOnly = options.HistorizedOnly,
|
||||
};
|
||||
|
||||
if (options.RootGobjectId.HasValue)
|
||||
{
|
||||
request.RootGobjectId = options.RootGobjectId.Value;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.RootTagName))
|
||||
{
|
||||
request.RootTagName = options.RootTagName;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.RootContainedPath))
|
||||
{
|
||||
request.RootContainedPath = options.RootContainedPath;
|
||||
}
|
||||
|
||||
if (options.MaxDepth.HasValue)
|
||||
{
|
||||
request.MaxDepth = options.MaxDepth.Value;
|
||||
}
|
||||
|
||||
request.CategoryIds.Add(options.CategoryIds);
|
||||
request.TemplateChainContains.Add(options.TemplateChainContains);
|
||||
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
|
||||
{
|
||||
request.TagNameGlob = options.TagNameGlob;
|
||||
}
|
||||
|
||||
if (options.IncludeAttributes.HasValue)
|
||||
{
|
||||
request.IncludeAttributes = options.IncludeAttributes.Value;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -80,6 +80,8 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
||||
{
|
||||
HttpHandler = handler,
|
||||
LoggerFactory = options.LoggerFactory,
|
||||
MaxReceiveMessageSize = options.MaxGrpcMessageBytes,
|
||||
MaxSendMessageSize = options.MaxGrpcMessageBytes,
|
||||
});
|
||||
|
||||
return new MxGatewayClient(
|
||||
|
||||
@@ -47,6 +47,8 @@ public sealed class MxGatewayClientOptions
|
||||
/// </summary>
|
||||
public TimeSpan? StreamTimeout { get; init; }
|
||||
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the retry configuration for safe unary calls.
|
||||
/// </summary>
|
||||
@@ -102,6 +104,13 @@ public sealed class MxGatewayClientOptions
|
||||
"The stream timeout must be greater than zero when configured.");
|
||||
}
|
||||
|
||||
if (MaxGrpcMessageBytes <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(MaxGrpcMessageBytes),
|
||||
"The maximum gRPC message size must be greater than zero.");
|
||||
}
|
||||
|
||||
if (UseTls && Endpoint.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
|
||||
+7
-3
@@ -11,6 +11,7 @@ import java.util.NoSuchElementException;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
|
||||
@@ -22,8 +23,8 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
private static final Object END = new Object();
|
||||
|
||||
private final BlockingQueue<Object> queue;
|
||||
private final AtomicBoolean closed = new AtomicBoolean();
|
||||
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
|
||||
private volatile boolean closed;
|
||||
private Object next;
|
||||
|
||||
DeployEventStream(int capacity) {
|
||||
@@ -35,6 +36,9 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
@Override
|
||||
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
|
||||
DeployEventStream.this.requestStream = requestStream;
|
||||
if (closed.get()) {
|
||||
requestStream.cancel("client cancelled deploy event stream", null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,7 +48,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
|
||||
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed.get()) {
|
||||
offer(END);
|
||||
return;
|
||||
}
|
||||
@@ -90,7 +94,7 @@ public final class DeployEventStream implements Iterator<DeployEvent>, AutoClose
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
closed.set(true);
|
||||
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
|
||||
if (stream != null) {
|
||||
stream.cancel("client cancelled deploy event stream", null);
|
||||
|
||||
+41
-6
@@ -36,6 +36,8 @@ import javax.net.ssl.SSLException;
|
||||
* {@link MxGatewayClient}.
|
||||
*/
|
||||
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||
|
||||
private final ManagedChannel ownedChannel;
|
||||
private final MxGatewayClientOptions options;
|
||||
private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub;
|
||||
@@ -177,9 +179,22 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
*/
|
||||
public List<GalaxyObject> discoverHierarchy() {
|
||||
try {
|
||||
DiscoverHierarchyReply reply =
|
||||
rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance());
|
||||
return reply.getObjectsList();
|
||||
java.util.ArrayList<GalaxyObject> objects = new java.util.ArrayList<>();
|
||||
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
|
||||
String pageToken = "";
|
||||
do {
|
||||
DiscoverHierarchyReply reply = rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.newBuilder()
|
||||
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||
.setPageToken(pageToken)
|
||||
.build());
|
||||
objects.addAll(reply.getObjectsList());
|
||||
pageToken = reply.getNextPageToken();
|
||||
if (!pageToken.isBlank() && !seenPageTokens.add(pageToken)) {
|
||||
throw new MxGatewayException(
|
||||
"galaxy discover hierarchy returned repeated page token: " + pageToken);
|
||||
}
|
||||
} while (!pageToken.isBlank());
|
||||
return objects;
|
||||
} catch (RuntimeException error) {
|
||||
if (error instanceof MxGatewayException) {
|
||||
throw error;
|
||||
@@ -195,8 +210,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
* exceptionally with {@link MxGatewayException} on failure
|
||||
*/
|
||||
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
|
||||
return toCompletable(rawFutureStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance()))
|
||||
.thenApply(DiscoverHierarchyReply::getObjectsList);
|
||||
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +309,7 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
|
||||
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(16 * 1024 * 1024);
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
@@ -327,6 +341,27 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
|
||||
}
|
||||
|
||||
private CompletableFuture<List<GalaxyObject>> discoverHierarchyPageAsync(
|
||||
String pageToken, java.util.ArrayList<GalaxyObject> objects, java.util.HashSet<String> seenPageTokens) {
|
||||
DiscoverHierarchyRequest request = DiscoverHierarchyRequest.newBuilder()
|
||||
.setPageSize(DISCOVER_HIERARCHY_PAGE_SIZE)
|
||||
.setPageToken(pageToken)
|
||||
.build();
|
||||
return toCompletable(rawFutureStub().discoverHierarchy(request)).thenCompose(reply -> {
|
||||
objects.addAll(reply.getObjectsList());
|
||||
if (reply.getNextPageToken().isBlank()) {
|
||||
return CompletableFuture.completedFuture(objects);
|
||||
}
|
||||
if (!seenPageTokens.add(reply.getNextPageToken())) {
|
||||
CompletableFuture<List<GalaxyObject>> failed = new CompletableFuture<>();
|
||||
failed.completeExceptionally(new MxGatewayException(
|
||||
"galaxy discover hierarchy returned repeated page token: " + reply.getNextPageToken()));
|
||||
return failed;
|
||||
}
|
||||
return discoverHierarchyPageAsync(reply.getNextPageToken(), objects, seenPageTokens);
|
||||
});
|
||||
}
|
||||
|
||||
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
|
||||
CompletableFuture<T> target = new CompletableFuture<>();
|
||||
Futures.addCallback(
|
||||
|
||||
+1
-1
@@ -284,7 +284,7 @@ public final class MxGatewayClient implements AutoCloseable {
|
||||
|
||||
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
|
||||
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
|
||||
.maxInboundMessageSize(16 * 1024 * 1024);
|
||||
.maxInboundMessageSize(options.maxGrpcMessageBytes());
|
||||
if (!options.connectTimeout().isNegative()) {
|
||||
builder.withOption(
|
||||
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
|
||||
|
||||
+17
@@ -14,6 +14,7 @@ import java.util.Objects;
|
||||
public final class MxGatewayClientOptions {
|
||||
private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration DEFAULT_CALL_TIMEOUT = Duration.ofSeconds(30);
|
||||
private static final int DEFAULT_MAX_GRPC_MESSAGE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
private final String endpoint;
|
||||
private final String apiKey;
|
||||
@@ -23,6 +24,7 @@ public final class MxGatewayClientOptions {
|
||||
private final Duration connectTimeout;
|
||||
private final Duration callTimeout;
|
||||
private final Duration streamTimeout;
|
||||
private final int maxGrpcMessageBytes;
|
||||
|
||||
private MxGatewayClientOptions(Builder builder) {
|
||||
endpoint = requireText(builder.endpoint, "endpoint");
|
||||
@@ -33,6 +35,9 @@ public final class MxGatewayClientOptions {
|
||||
connectTimeout = builder.connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : builder.connectTimeout;
|
||||
callTimeout = builder.callTimeout == null ? DEFAULT_CALL_TIMEOUT : builder.callTimeout;
|
||||
streamTimeout = builder.streamTimeout;
|
||||
maxGrpcMessageBytes = builder.maxGrpcMessageBytes <= 0
|
||||
? DEFAULT_MAX_GRPC_MESSAGE_BYTES
|
||||
: builder.maxGrpcMessageBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +131,10 @@ public final class MxGatewayClientOptions {
|
||||
return streamTimeout;
|
||||
}
|
||||
|
||||
public int maxGrpcMessageBytes() {
|
||||
return maxGrpcMessageBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "MxGatewayClientOptions{"
|
||||
@@ -148,6 +157,8 @@ public final class MxGatewayClientOptions {
|
||||
+ callTimeout
|
||||
+ ", streamTimeout="
|
||||
+ streamTimeout
|
||||
+ ", maxGrpcMessageBytes="
|
||||
+ maxGrpcMessageBytes
|
||||
+ '}';
|
||||
}
|
||||
|
||||
@@ -170,6 +181,7 @@ public final class MxGatewayClientOptions {
|
||||
private Duration connectTimeout;
|
||||
private Duration callTimeout;
|
||||
private Duration streamTimeout;
|
||||
private int maxGrpcMessageBytes;
|
||||
|
||||
private Builder() {
|
||||
}
|
||||
@@ -265,6 +277,11 @@ public final class MxGatewayClientOptions {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Builder maxGrpcMessageBytes(int value) {
|
||||
maxGrpcMessageBytes = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an immutable {@link MxGatewayClientOptions} from the current state.
|
||||
*
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ package com.dohertylan.mxgateway.client;
|
||||
* worker speak the same protocol version as the client.
|
||||
*/
|
||||
public final class MxGatewayClientVersion {
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 1;
|
||||
private static final int GATEWAY_PROTOCOL_VERSION = 2;
|
||||
private static final int WORKER_PROTOCOL_VERSION = 1;
|
||||
private static final String CLIENT_VERSION = "0.1.0";
|
||||
|
||||
|
||||
+121
-22
@@ -3,6 +3,7 @@ package com.dohertylan.mxgateway.client;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.protobuf.Timestamp;
|
||||
@@ -25,6 +26,8 @@ import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -100,31 +103,44 @@ final class GalaxyRepositoryClientTests {
|
||||
|
||||
@Test
|
||||
void discoverHierarchyReturnsObjectsAndAttributes() throws Exception {
|
||||
AtomicReference<DiscoverHierarchyRequest> seenRequest = new AtomicReference<>();
|
||||
AtomicReference<DiscoverHierarchyRequest> firstRequest = new AtomicReference<>();
|
||||
AtomicReference<DiscoverHierarchyRequest> secondRequest = new AtomicReference<>();
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void discoverHierarchy(
|
||||
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
|
||||
seenRequest.set(request);
|
||||
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
|
||||
.addObjects(GalaxyObject.newBuilder()
|
||||
.setGobjectId(7)
|
||||
.setTagName("Pump_001")
|
||||
.setContainedName("Pump")
|
||||
.setBrowseName("Pump")
|
||||
.setParentGobjectId(1)
|
||||
.setIsArea(false)
|
||||
.setCategoryId(3)
|
||||
.setHostedByGobjectId(0)
|
||||
.addTemplateChain("$Pump")
|
||||
.addAttributes(GalaxyAttribute.newBuilder()
|
||||
.setAttributeName("Speed")
|
||||
.setFullTagReference("Pump_001.Speed")
|
||||
.setMxDataType(5)
|
||||
.setDataTypeName("MxFloat")
|
||||
.setIsArray(false)
|
||||
.setIsHistorized(true)))
|
||||
.build());
|
||||
if (request.getPageToken().isEmpty()) {
|
||||
firstRequest.set(request);
|
||||
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
|
||||
.setNextPageToken("page-2")
|
||||
.setTotalObjectCount(2)
|
||||
.addObjects(GalaxyObject.newBuilder()
|
||||
.setGobjectId(7)
|
||||
.setTagName("Pump_001")
|
||||
.setContainedName("Pump")
|
||||
.setBrowseName("Pump")
|
||||
.setParentGobjectId(1)
|
||||
.setIsArea(false)
|
||||
.setCategoryId(3)
|
||||
.setHostedByGobjectId(0)
|
||||
.addTemplateChain("$Pump")
|
||||
.addAttributes(GalaxyAttribute.newBuilder()
|
||||
.setAttributeName("Speed")
|
||||
.setFullTagReference("Pump_001.Speed")
|
||||
.setMxDataType(5)
|
||||
.setDataTypeName("MxFloat")
|
||||
.setIsArray(false)
|
||||
.setIsHistorized(true)))
|
||||
.build());
|
||||
} else {
|
||||
secondRequest.set(request);
|
||||
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
|
||||
.setTotalObjectCount(2)
|
||||
.addObjects(GalaxyObject.newBuilder()
|
||||
.setGobjectId(8)
|
||||
.setTagName("Pump_002"))
|
||||
.build());
|
||||
}
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
@@ -132,7 +148,10 @@ final class GalaxyRepositoryClientTests {
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<GalaxyObject> objects = client.discoverHierarchy();
|
||||
assertEquals(1, objects.size());
|
||||
assertEquals(2, objects.size());
|
||||
assertEquals(5000, firstRequest.get().getPageSize());
|
||||
assertEquals("", firstRequest.get().getPageToken());
|
||||
assertEquals("page-2", secondRequest.get().getPageToken());
|
||||
GalaxyObject only = objects.get(0);
|
||||
assertEquals(7, only.getGobjectId());
|
||||
assertEquals("Pump_001", only.getTagName());
|
||||
@@ -142,6 +161,41 @@ final class GalaxyRepositoryClientTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void deployEventStreamCloseBeforeBeforeStartCancelsStream() {
|
||||
DeployEventStream stream = new DeployEventStream(4);
|
||||
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer = stream.observer();
|
||||
RecordingClientCallStreamObserver requestStream = new RecordingClientCallStreamObserver();
|
||||
|
||||
stream.close();
|
||||
observer.beforeStart(requestStream);
|
||||
|
||||
assertTrue(requestStream.cancelled);
|
||||
assertEquals("client cancelled deploy event stream", requestStream.cancelMessage);
|
||||
assertFalse(stream.hasNext());
|
||||
}
|
||||
|
||||
@Test
|
||||
void discoverHierarchyRejectsRepeatedPageToken() throws Exception {
|
||||
TestService service = new TestService() {
|
||||
@Override
|
||||
public void discoverHierarchy(
|
||||
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
|
||||
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
|
||||
.setNextPageToken("7:1")
|
||||
.build());
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, client::discoverHierarchy);
|
||||
|
||||
assertTrue(error.getMessage().contains("repeated page token"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||
DeployEvent first = DeployEvent.newBuilder()
|
||||
@@ -281,6 +335,51 @@ final class GalaxyRepositoryClientTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingClientCallStreamObserver
|
||||
extends ClientCallStreamObserver<WatchDeployEventsRequest> {
|
||||
private boolean cancelled;
|
||||
private String cancelMessage;
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnReadyHandler(Runnable onReadyHandler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disableAutoInboundFlowControl() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void request(int count) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setMessageCompression(boolean enable) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cancel(String message, Throwable cause) {
|
||||
cancelled = true;
|
||||
cancelMessage = message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(WatchDeployEventsRequest value) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleted() {
|
||||
}
|
||||
}
|
||||
|
||||
private record InProcessGalaxy(Server server, ManagedChannel channel) implements AutoCloseable {
|
||||
static InProcessGalaxy start(
|
||||
GalaxyRepositoryGrpc.GalaxyRepositoryImplBase service, AtomicReference<String> authorization)
|
||||
|
||||
+2540
-93
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"fixtureSet": "mxaccess-gateway-client-behavior",
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoInputManifest": "clients/proto/proto-inputs.json",
|
||||
"fixtures": [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"backendName": "mxaccess-worker",
|
||||
"workerProcessId": 1234,
|
||||
"workerProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"capabilities": [
|
||||
"unary-open-session",
|
||||
"unary-close-session",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"fixtureSet": "mxaccess-gateway-parity-fixture-matrix",
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"workerProtocolVersion": 1,
|
||||
"sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures",
|
||||
"sourceDocs": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"contractName": "mxaccess-gateway",
|
||||
"gatewayProtocolVersion": 1,
|
||||
"gatewayProtocolVersion": 2,
|
||||
"workerProtocolVersion": 1,
|
||||
"protoRoot": "src/MxGateway.Contracts/Protos",
|
||||
"sourceFiles": [
|
||||
|
||||
@@ -18,11 +18,13 @@ import grpc
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
from .auth import merge_metadata
|
||||
from .errors import map_rpc_error
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
|
||||
from .options import ClientOptions, create_channel
|
||||
|
||||
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
|
||||
|
||||
|
||||
class GalaxyRepositoryClient:
|
||||
"""Async client for the Galaxy Repository gRPC service."""
|
||||
@@ -115,12 +117,27 @@ class GalaxyRepositoryClient:
|
||||
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
|
||||
"""Return the deployed Galaxy object hierarchy as raw proto messages."""
|
||||
|
||||
reply = await self._unary(
|
||||
"discover hierarchy",
|
||||
self.raw_stub.DiscoverHierarchy,
|
||||
galaxy_pb.DiscoverHierarchyRequest(),
|
||||
)
|
||||
return list(reply.objects)
|
||||
objects: list[galaxy_pb.GalaxyObject] = []
|
||||
seen_page_tokens: set[str] = set()
|
||||
page_token = ""
|
||||
while True:
|
||||
reply = await self._unary(
|
||||
"discover hierarchy",
|
||||
self.raw_stub.DiscoverHierarchy,
|
||||
galaxy_pb.DiscoverHierarchyRequest(
|
||||
page_size=_DISCOVER_HIERARCHY_PAGE_SIZE,
|
||||
page_token=page_token,
|
||||
),
|
||||
)
|
||||
objects.extend(reply.objects)
|
||||
page_token = reply.next_page_token
|
||||
if not page_token:
|
||||
return objects
|
||||
if page_token in seen_page_tokens:
|
||||
raise MxGatewayError(
|
||||
f"galaxy discover hierarchy returned repeated page token {page_token!r}"
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
def watch_deploy_events(
|
||||
self,
|
||||
|
||||
@@ -23,9 +23,10 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
|
||||
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x18\x44iscoverHierarchyRequest\"M\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -33,26 +34,26 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _gl
|
||||
if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['DESCRIPTOR']._loaded_options = None
|
||||
_globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy'
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_start=82
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_end=105
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_start=107
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_end=140
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=142
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=168
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=170
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=268
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=270
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=296
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=298
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=375
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=377
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=462
|
||||
_globals['_DEPLOYEVENT']._serialized_start=465
|
||||
_globals['_DEPLOYEVENT']._serialized_end=686
|
||||
_globals['_GALAXYOBJECT']._serialized_start=689
|
||||
_globals['_GALAXYOBJECT']._serialized_end=964
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_start=967
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1263
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=1266
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=1726
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_start=114
|
||||
_globals['_TESTCONNECTIONREQUEST']._serialized_end=137
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_start=139
|
||||
_globals['_TESTCONNECTIONREPLY']._serialized_end=172
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=174
|
||||
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=200
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=202
|
||||
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=300
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=303
|
||||
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=694
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=697
|
||||
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=827
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=829
|
||||
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=914
|
||||
_globals['_DEPLOYEVENT']._serialized_start=917
|
||||
_globals['_DEPLOYEVENT']._serialized_end=1138
|
||||
_globals['_GALAXYOBJECT']._serialized_start=1141
|
||||
_globals['_GALAXYOBJECT']._serialized_end=1416
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=1718
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=2178
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -21,6 +21,7 @@ class ClientOptions:
|
||||
server_name_override: str | None = None
|
||||
call_timeout: float | None = 30.0
|
||||
stream_timeout: float | None = None
|
||||
max_grpc_message_bytes: int = 16 * 1024 * 1024
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate options; raise `ValueError` for invalid combinations."""
|
||||
@@ -33,6 +34,8 @@ class ClientOptions:
|
||||
raise ValueError("call_timeout must be greater than zero")
|
||||
if self.stream_timeout is not None and self.stream_timeout <= 0:
|
||||
raise ValueError("stream_timeout must be greater than zero")
|
||||
if self.max_grpc_message_bytes <= 0:
|
||||
raise ValueError("max_grpc_message_bytes must be greater than zero")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a repr that redacts the API key value."""
|
||||
@@ -43,14 +46,18 @@ class ClientOptions:
|
||||
f"ca_file={self.ca_file!r}, "
|
||||
f"server_name_override={self.server_name_override!r}, "
|
||||
f"call_timeout={self.call_timeout!r}, "
|
||||
f"stream_timeout={self.stream_timeout!r})"
|
||||
f"stream_timeout={self.stream_timeout!r}, "
|
||||
f"max_grpc_message_bytes={self.max_grpc_message_bytes!r})"
|
||||
)
|
||||
|
||||
|
||||
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
||||
|
||||
channel_options: list[tuple[str, str]] = []
|
||||
channel_options: list[tuple[str, str | int]] = [
|
||||
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
|
||||
("grpc.max_send_message_length", options.max_grpc_message_bytes),
|
||||
]
|
||||
if options.server_name_override:
|
||||
channel_options.append(("grpc.ssl_target_name_override", options.server_name_override))
|
||||
|
||||
|
||||
@@ -61,7 +61,15 @@ def test_create_channel_uses_plaintext_channel(monkeypatch: pytest.MonkeyPatch)
|
||||
channel = create_channel(ClientOptions(endpoint="localhost:5000", plaintext=True))
|
||||
|
||||
assert channel == "plain-channel"
|
||||
assert calls == [("localhost:5000", [])]
|
||||
assert calls == [
|
||||
(
|
||||
"localhost:5000",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -95,9 +103,13 @@ def test_create_channel_uses_tls_channel(monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
|
||||
assert channel == "tls-channel"
|
||||
assert calls == [
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[("grpc.ssl_target_name_override", "gateway.test")],
|
||||
),
|
||||
]
|
||||
(
|
||||
"gateway.example:5001",
|
||||
"creds",
|
||||
[
|
||||
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
||||
("grpc.max_send_message_length", 16 * 1024 * 1024),
|
||||
("grpc.ssl_target_name_override", "gateway.test"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@@ -98,6 +98,8 @@ async def test_discover_hierarchy_returns_proto_objects() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.discover_hierarchy.replies = [
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
next_page_token="page-2",
|
||||
total_object_count=2,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(
|
||||
gobject_id=1,
|
||||
@@ -106,6 +108,11 @@ async def test_discover_hierarchy_returns_proto_objects() -> None:
|
||||
browse_name="TestMachine_001",
|
||||
is_area=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
galaxy_pb.DiscoverHierarchyReply(
|
||||
total_object_count=2,
|
||||
objects=[
|
||||
galaxy_pb.GalaxyObject(
|
||||
gobject_id=2,
|
||||
tag_name="DelmiaReceiver_001",
|
||||
@@ -133,10 +140,30 @@ async def test_discover_hierarchy_returns_proto_objects() -> None:
|
||||
|
||||
assert isinstance(objects, list)
|
||||
assert len(objects) == 2
|
||||
assert len(stub.discover_hierarchy.requests) == 2
|
||||
assert stub.discover_hierarchy.requests[0].page_size == 5000
|
||||
assert stub.discover_hierarchy.requests[0].page_token == ""
|
||||
assert stub.discover_hierarchy.requests[1].page_token == "page-2"
|
||||
assert objects[0].tag_name == "TestMachine_001"
|
||||
assert objects[1].attributes[0].full_tag_reference == "DelmiaReceiver_001.DownloadPath"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_hierarchy_rejects_repeated_page_token() -> None:
|
||||
stub = FakeGalaxyStub()
|
||||
stub.discover_hierarchy.replies = [
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
galaxy_pb.DiscoverHierarchyReply(next_page_token="7:1"),
|
||||
]
|
||||
client = await GalaxyRepositoryClient.connect(
|
||||
ClientOptions(endpoint="fake", plaintext=True),
|
||||
stub=stub,
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="repeated page token"):
|
||||
await client.discover_hierarchy()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_deploy_events_yields_events_in_order() -> None:
|
||||
ts1 = Timestamp()
|
||||
|
||||
@@ -1048,7 +1048,7 @@ mod tests {
|
||||
fn version_json_output_has_protocol_versions() {
|
||||
let value = super::version_json();
|
||||
|
||||
assert_eq!(value["gatewayProtocolVersion"], 1);
|
||||
assert_eq!(value["gatewayProtocolVersion"], 2);
|
||||
assert_eq!(value["workerProtocolVersion"], 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,9 +79,12 @@ impl GatewayClient {
|
||||
|
||||
let channel = endpoint.connect().await?;
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
|
||||
Ok(Self {
|
||||
inner: MxAccessGatewayClient::with_interceptor(channel, interceptor),
|
||||
inner: MxAccessGatewayClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
})
|
||||
|
||||
+140
-33
@@ -21,6 +21,8 @@ use crate::generated::galaxy_repository::v1::{
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
|
||||
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
|
||||
|
||||
/// Convenience alias for the generated Galaxy client wrapped in the
|
||||
/// authentication interceptor.
|
||||
pub type RawGalaxyClient = GalaxyRepositoryClient<InterceptedService<Channel, AuthInterceptor>>;
|
||||
@@ -77,9 +79,12 @@ impl GalaxyClient {
|
||||
|
||||
let channel = endpoint.connect().await?;
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
|
||||
Ok(Self {
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
})
|
||||
@@ -89,8 +94,11 @@ impl GalaxyClient {
|
||||
/// channel. Tests use this to wire up an in-memory transport.
|
||||
pub fn from_channel(channel: Channel, options: &ClientOptions) -> Self {
|
||||
let interceptor = AuthInterceptor::new(options.api_key().cloned());
|
||||
let max_grpc_message_bytes = options.max_grpc_message_bytes();
|
||||
Self {
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
|
||||
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor)
|
||||
.max_decoding_message_size(max_grpc_message_bytes)
|
||||
.max_encoding_message_size(max_grpc_message_bytes),
|
||||
call_timeout: options.call_timeout(),
|
||||
stream_timeout: options.stream_timeout(),
|
||||
}
|
||||
@@ -135,11 +143,33 @@ impl GalaxyClient {
|
||||
/// Walk the deployed object hierarchy. Each [`GalaxyObject`] contains
|
||||
/// the object's identifying names plus its dynamic attributes.
|
||||
pub async fn discover_hierarchy(&mut self) -> Result<Vec<GalaxyObject>, Error> {
|
||||
let response = self
|
||||
.inner
|
||||
.discover_hierarchy(self.unary_request(DiscoverHierarchyRequest {}))
|
||||
.await?;
|
||||
Ok(response.into_inner().objects)
|
||||
let mut objects = Vec::new();
|
||||
let mut seen_page_tokens = std::collections::HashSet::new();
|
||||
let mut page_token = String::new();
|
||||
loop {
|
||||
let response = self
|
||||
.inner
|
||||
.discover_hierarchy(self.unary_request(DiscoverHierarchyRequest {
|
||||
page_size: DISCOVER_HIERARCHY_PAGE_SIZE,
|
||||
page_token,
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
let reply = response.into_inner();
|
||||
objects.extend(reply.objects);
|
||||
page_token = reply.next_page_token;
|
||||
if page_token.is_empty() {
|
||||
return Ok(objects);
|
||||
}
|
||||
if !seen_page_tokens.insert(page_token.clone()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!(
|
||||
"galaxy discover hierarchy returned repeated page token `{page_token}`"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe to the server-streamed deploy-event feed.
|
||||
@@ -217,6 +247,8 @@ mod tests {
|
||||
present: Mutex<bool>,
|
||||
last_deploy: Mutex<Option<Timestamp>>,
|
||||
objects: Mutex<Vec<GalaxyObject>>,
|
||||
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
||||
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
|
||||
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
|
||||
watch_events: Mutex<Vec<DeployEvent>>,
|
||||
watch_senders: Mutex<Vec<DeployEventTx>>,
|
||||
@@ -256,10 +288,21 @@ mod tests {
|
||||
|
||||
async fn discover_hierarchy(
|
||||
&self,
|
||||
_request: Request<DiscoverHierarchyRequest>,
|
||||
request: Request<DiscoverHierarchyRequest>,
|
||||
) -> Result<Response<DiscoverHierarchyReply>, Status> {
|
||||
self.state
|
||||
.discover_requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.into_inner());
|
||||
if let Some(reply) = self.state.discover_replies.lock().unwrap().pop_front() {
|
||||
return Ok(Response::new(reply));
|
||||
}
|
||||
|
||||
Ok(Response::new(DiscoverHierarchyReply {
|
||||
objects: self.state.objects.lock().unwrap().clone(),
|
||||
next_page_token: String::new(),
|
||||
total_object_count: self.state.objects.lock().unwrap().len() as i32,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -409,30 +452,58 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn discover_hierarchy_returns_objects_with_attributes() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
*state.objects.lock().unwrap() = vec![GalaxyObject {
|
||||
gobject_id: 42,
|
||||
tag_name: "DelmiaReceiver_001".to_owned(),
|
||||
contained_name: "DelmiaReceiver".to_owned(),
|
||||
browse_name: "TestMachine_001/DelmiaReceiver".to_owned(),
|
||||
parent_gobject_id: 7,
|
||||
is_area: false,
|
||||
category_id: 3,
|
||||
hosted_by_gobject_id: 1,
|
||||
template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()],
|
||||
attributes: vec![GalaxyAttribute {
|
||||
attribute_name: "DownloadPath".to_owned(),
|
||||
full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(),
|
||||
mx_data_type: 8,
|
||||
data_type_name: "MxString".to_owned(),
|
||||
is_array: false,
|
||||
array_dimension: 0,
|
||||
array_dimension_present: false,
|
||||
mx_attribute_category: 2,
|
||||
security_classification: 1,
|
||||
is_historized: false,
|
||||
is_alarm: false,
|
||||
}],
|
||||
}];
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: vec![GalaxyObject {
|
||||
gobject_id: 42,
|
||||
tag_name: "DelmiaReceiver_001".to_owned(),
|
||||
contained_name: "DelmiaReceiver".to_owned(),
|
||||
browse_name: "TestMachine_001/DelmiaReceiver".to_owned(),
|
||||
parent_gobject_id: 7,
|
||||
is_area: false,
|
||||
category_id: 3,
|
||||
hosted_by_gobject_id: 1,
|
||||
template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()],
|
||||
attributes: vec![GalaxyAttribute {
|
||||
attribute_name: "DownloadPath".to_owned(),
|
||||
full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(),
|
||||
mx_data_type: 8,
|
||||
data_type_name: "MxString".to_owned(),
|
||||
is_array: false,
|
||||
array_dimension: 0,
|
||||
array_dimension_present: false,
|
||||
mx_attribute_category: 2,
|
||||
security_classification: 1,
|
||||
is_historized: false,
|
||||
is_alarm: false,
|
||||
}],
|
||||
}],
|
||||
next_page_token: "page-2".to_owned(),
|
||||
total_object_count: 2,
|
||||
});
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: vec![GalaxyObject {
|
||||
gobject_id: 43,
|
||||
tag_name: "DelmiaReceiver_002".to_owned(),
|
||||
contained_name: String::new(),
|
||||
browse_name: String::new(),
|
||||
parent_gobject_id: 0,
|
||||
is_area: false,
|
||||
category_id: 0,
|
||||
hosted_by_gobject_id: 0,
|
||||
template_chain: Vec::new(),
|
||||
attributes: Vec::new(),
|
||||
}],
|
||||
next_page_token: String::new(),
|
||||
total_object_count: 2,
|
||||
});
|
||||
let endpoint = spawn_fake(state.clone()).await;
|
||||
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
@@ -441,7 +512,12 @@ mod tests {
|
||||
|
||||
let objects = client.discover_hierarchy().await.unwrap();
|
||||
|
||||
assert_eq!(objects.len(), 1);
|
||||
assert_eq!(objects.len(), 2);
|
||||
let requests = state.discover_requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[0].page_size, 5000);
|
||||
assert_eq!(requests[0].page_token, "");
|
||||
assert_eq!(requests[1].page_token, "page-2");
|
||||
assert_eq!(objects[0].tag_name, "DelmiaReceiver_001");
|
||||
assert_eq!(objects[0].attributes.len(), 1);
|
||||
assert_eq!(objects[0].attributes[0].attribute_name, "DownloadPath");
|
||||
@@ -451,6 +527,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discover_hierarchy_rejects_repeated_page_token() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: Vec::new(),
|
||||
next_page_token: "7:1".to_owned(),
|
||||
total_object_count: 1,
|
||||
});
|
||||
state
|
||||
.discover_replies
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(DiscoverHierarchyReply {
|
||||
objects: Vec::new(),
|
||||
next_page_token: "7:1".to_owned(),
|
||||
total_object_count: 1,
|
||||
});
|
||||
let endpoint = spawn_fake(state).await;
|
||||
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let error = client.discover_hierarchy().await.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("repeated page token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watch_deploy_events_yields_events_in_order() {
|
||||
let state = Arc::new(FakeState::default());
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::time::Duration;
|
||||
|
||||
use crate::auth::ApiKey;
|
||||
|
||||
const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Configuration for connecting to a gateway endpoint.
|
||||
///
|
||||
/// Defaults are 10s connect timeout, 30s call timeout, no streaming timeout,
|
||||
@@ -24,6 +26,7 @@ pub struct ClientOptions {
|
||||
connect_timeout: Duration,
|
||||
call_timeout: Duration,
|
||||
stream_timeout: Option<Duration>,
|
||||
max_grpc_message_bytes: usize,
|
||||
}
|
||||
|
||||
impl ClientOptions {
|
||||
@@ -39,6 +42,7 @@ impl ClientOptions {
|
||||
connect_timeout: Duration::from_secs(10),
|
||||
call_timeout: Duration::from_secs(30),
|
||||
stream_timeout: None,
|
||||
max_grpc_message_bytes: DEFAULT_MAX_GRPC_MESSAGE_BYTES,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +95,11 @@ impl ClientOptions {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_grpc_message_bytes(mut self, max_grpc_message_bytes: usize) -> Self {
|
||||
self.max_grpc_message_bytes = max_grpc_message_bytes;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configured endpoint URL.
|
||||
pub fn endpoint(&self) -> &str {
|
||||
&self.endpoint
|
||||
@@ -130,6 +139,10 @@ impl ClientOptions {
|
||||
pub fn stream_timeout(&self) -> Option<Duration> {
|
||||
self.stream_timeout
|
||||
}
|
||||
|
||||
pub fn max_grpc_message_bytes(&self) -> usize {
|
||||
self.max_grpc_message_bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClientOptions {
|
||||
@@ -150,6 +163,7 @@ impl fmt::Debug for ClientOptions {
|
||||
.field("connect_timeout", &self.connect_timeout)
|
||||
.field("call_timeout", &self.call_timeout)
|
||||
.field("stream_timeout", &self.stream_timeout)
|
||||
.field("max_grpc_message_bytes", &self.max_grpc_message_bytes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
pub const CLIENT_VERSION: &str = "0.1.0-dev";
|
||||
|
||||
/// Public gateway gRPC protocol version this client targets.
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 1;
|
||||
pub const GATEWAY_PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
/// Internal worker IPC protocol version this client expects sessions to use.
|
||||
pub const WORKER_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
@@ -6,7 +6,7 @@ what an authenticated API key can browse, read, or write inside the Galaxy.
|
||||
|
||||
## Overview
|
||||
|
||||
Authorization runs as a single gRPC server interceptor registered for every call on the gateway. It pulls the authenticated identity for the current request, derives the scope that the request type requires, and either lets the call continue or fails the call with a gRPC status. The pipeline keeps service classes free of cross-cutting checks, which matches the AGENTS.md "thin gRPC layer" rule that service handlers translate between contracts and domain code without owning policy.
|
||||
Authorization runs as a single gRPC server interceptor registered for every call on the gateway. It pulls the authenticated identity for the current request, derives the scope that the request type requires, and either lets the call continue or fails the call with a gRPC status. The pipeline keeps service classes free of cross-cutting checks, which matches the `gateway.md` "thin gRPC layer" rule that service handlers translate between contracts and domain code without owning policy.
|
||||
|
||||
The participating types live under `src/MxGateway.Server/Security/Authorization/`:
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ The diagnostics subsystem provides structured logging, credential redaction, and
|
||||
|
||||
## Goals
|
||||
|
||||
The subsystem exists to satisfy two security rules from `AGENTS.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
The subsystem exists to satisfy two security rules from `gateway.md`: never log passwords or raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations, and never log full MXAccess values by default. Code paths that touch credentials or tag values must therefore route through `GatewayLogRedactor` rather than emitting them directly.
|
||||
|
||||
A second goal is parity-test diagnosability. Because MXAccess sessions, workers, correlation ids, and command methods are the units of comparison, every log entry produced inside a request scope must carry those identifiers without each call site having to format them.
|
||||
|
||||
@@ -80,7 +80,7 @@ public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
}
|
||||
```
|
||||
|
||||
The names match the MXAccess command list in `AGENTS.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
|
||||
The names match the MXAccess command list in `gateway.md` exactly. `Write` and `Write2` are not in the set because their payloads are tag values, not credentials, and are governed by the `valueLoggingEnabled` flag described below.
|
||||
|
||||
### API key redaction
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ The gRPC service layer is the public entry point for client traffic. It is inten
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
The architecture rule (from `AGENTS.md`) is that the gRPC layer must "validate request, find session, call the session worker client, map worker replies to public replies, and stream events". Anything else — caching, retries, worker process lifetime, event ordering — lives behind `ISessionManager` and the worker client. Keeping the layer thin lets the same session/worker code be reused by future transports (for example, an in-process host or an alternate IPC) without having to re-derive validation or mapping rules.
|
||||
The architecture rule (from `gateway.md`) is that the gRPC layer must "validate request, find session, call the session worker client, map worker replies to public replies, and stream events". Anything else — caching, retries, worker process lifetime, event ordering — lives behind `ISessionManager` and the worker client. Keeping the layer thin lets the same session/worker code be reused by future transports (for example, an in-process host or an alternate IPC) without having to re-derive validation or mapping rules.
|
||||
|
||||
The layer is composed of four collaborators:
|
||||
|
||||
|
||||
@@ -177,7 +177,7 @@ private void Write(
|
||||
|
||||
### What the redactor redacts and why
|
||||
|
||||
`AGENTS.md` "Security And Logging" requires that the worker never log raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations. The bootstrap nonce is also a credential: anyone who reads it can impersonate the worker to the gateway pipe. `WorkerLogRedactor` enforces this by replacing values whose field name contains any of these substrings (case-insensitive) with the literal `[redacted]`:
|
||||
`gateway.md` "Security" requires that the worker never log raw credential values for `AuthenticateUser`, `WriteSecured`, or related secured operations. The bootstrap nonce is also a credential: anyone who reads it can impersonate the worker to the gateway pipe. `WorkerLogRedactor` enforces this by replacing values whose field name contains any of these substrings (case-insensitive) with the literal `[redacted]`:
|
||||
|
||||
```csharp
|
||||
private static readonly string[] SensitiveFieldNameParts =
|
||||
|
||||
@@ -4,7 +4,7 @@ The conversion layer in `MxGateway.Worker.Conversion` projects COM `VARIANT` pay
|
||||
|
||||
## Overview
|
||||
|
||||
`AGENTS.md` (section "Value And Status Rules") requires that the wire format use a value union capable of representing COM `VARIANT` values and arrays, that lossy conversions retain both the typed projection and raw diagnostic metadata, and that `MXSTATUS_PROXY` arrays never collapse to a single success flag. The types in `src/MxGateway.Worker/Conversion/` are the worker-side enforcement of those rules.
|
||||
`gateway.md` (sections "Value Model" and "Status Model") requires that the wire format use a value union capable of representing COM `VARIANT` values and arrays, that lossy conversions retain both the typed projection and raw diagnostic metadata, and that `MXSTATUS_PROXY` arrays never collapse to a single success flag. The types in `src/MxGateway.Worker/Conversion/` are the worker-side enforcement of those rules.
|
||||
|
||||
The layer is split into three concerns:
|
||||
|
||||
@@ -90,7 +90,7 @@ MxDataType elementDataType = ResolveArrayElementDataType(elementType, expectedEl
|
||||
mxArray.ElementDataType = elementDataType;
|
||||
```
|
||||
|
||||
When the element type cannot be classified, `ConvertArray` does not throw. It downgrades the result to `MxDataType.Unknown`, records the original expected type in `RawElementDataType`, and serializes each element via `ConvertRawArray` as a UTF-8 byte string. This satisfies the AGENTS.md requirement to keep both the best typed projection and the raw diagnostic metadata.
|
||||
When the element type cannot be classified, `ConvertArray` does not throw. It downgrades the result to `MxDataType.Unknown`, records the original expected type in `RawElementDataType`, and serializes each element via `ConvertRawArray` as a UTF-8 byte string. This satisfies the `gateway.md` requirement to keep both the best typed projection and the raw diagnostic metadata.
|
||||
|
||||
```csharp
|
||||
default:
|
||||
@@ -201,7 +201,7 @@ foreach (object? status in statuses)
|
||||
|
||||
### Why arrays are not collapsed
|
||||
|
||||
A single MXAccess command (notably `Read`, `Write`, and event callbacks) can return one status per item handle. AGENTS.md requires that the wire format represent each entry independently, because collapsing them to a Boolean success flag hides partial failures: a 50-item write where one item fails would be indistinguishable from a 50-item write where every item failed. Preserving the array per-position lets clients correlate each `MxStatusProxy` with its item handle and `MxValue`.
|
||||
A single MXAccess command (notably `Read`, `Write`, and event callbacks) can return one status per item handle. `gateway.md` requires that the wire format represent each entry independently, because collapsing them to a Boolean success flag hides partial failures: a 50-item write where one item fails would be indistinguishable from a 50-item write where every item failed. Preserving the array per-position lets clients correlate each `MxStatusProxy` with its item handle and `MxValue`.
|
||||
|
||||
### Completion-only status fallback
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ The worker STA runtime owns the dedicated single-threaded apartment thread that
|
||||
|
||||
## Why an STA Is Required
|
||||
|
||||
The installed MXAccess interop assembly declares an `Apartment` threading model (see `AGENTS.md` under "Worker Rules"). COM objects with that model must be created and called on a thread initialized as a single-threaded apartment, and any callbacks the object raises (event sink calls) are delivered through the thread's Windows message queue. A plain blocking queue is not sufficient: the STA loop must pump Windows messages so that the COM marshaler can deliver event invocations on the same thread that holds the object. Because of that constraint, every MXAccess operation in the worker is funneled through the types in `src/MxGateway.Worker/Sta/`.
|
||||
The installed MXAccess interop assembly declares an `Apartment` threading model (see `gateway.md` under "STA Worker Thread Model"). COM objects with that model must be created and called on a thread initialized as a single-threaded apartment, and any callbacks the object raises (event sink calls) are delivered through the thread's Windows message queue. A plain blocking queue is not sufficient: the STA loop must pump Windows messages so that the COM marshaler can deliver event invocations on the same thread that holds the object. Because of that constraint, every MXAccess operation in the worker is funneled through the types in `src/MxGateway.Worker/Sta/`.
|
||||
|
||||
## Types
|
||||
|
||||
@@ -131,11 +131,11 @@ finally
|
||||
|
||||
`SetCurrentCommand` records the in-flight `CorrelationId` so `PopulateHeartbeat` can publish both `PendingCommandCount` and `CurrentCommandCorrelationId` on the worker heartbeat. Exceptions are converted through `HResultConverter` so the IPC reply still carries a structured `ProtocolStatus`, an HRESULT, and a diagnostic message instead of an unhandled fault. `NormalizeReply` backfills `SessionId`, `CorrelationId`, `Kind`, and a default `ProtocolStatusCode.Ok` so executors can return minimal replies without restating the envelope.
|
||||
|
||||
`CancelQueuedCommand` walks the queue and completes a single matching entry with `ProtocolStatusCode.Canceled`. It cannot abort a command already running on the STA: per `AGENTS.md`, "Canceling a gRPC call should stop waiting in the gateway, but it cannot safely abort an in-flight COM call on the STA. Hard cancellation means killing the worker process."
|
||||
`CancelQueuedCommand` walks the queue and completes a single matching entry with `ProtocolStatusCode.Canceled`. It cannot abort a command already running on the STA: per `gateway.md`, "Canceling a gRPC call should stop waiting in the gateway, but it cannot safely abort an in-flight COM call on the STA. Hard cancellation means killing the worker process."
|
||||
|
||||
## Why the STA Loop Cannot Block on I/O
|
||||
|
||||
`AGENTS.md` states explicitly: "Do not block the STA on pipe writes, gRPC calls, or slow consumers. Event handlers should convert event args, enqueue outbound events, and return to pumping messages." The STA thread is the only thread that can service COM event callbacks, so any work that blocks it stalls every event the MXAccess object would otherwise deliver. The runtime keeps to that rule by giving the STA only two responsibilities inside `ThreadMain`: executing already-queued work items and pumping messages. Outbound event delivery and pipe writes happen on threads that observe the queues populated from the STA, never on the STA itself.
|
||||
`gateway.md` states explicitly: "Do not block the STA on pipe writes, gRPC calls, or slow consumers. Event handlers should convert event args, enqueue outbound events, and return to pumping messages." The STA thread is the only thread that can service COM event callbacks, so any work that blocks it stalls every event the MXAccess object would otherwise deliver. The runtime keeps to that rule by giving the STA only two responsibilities inside `ThreadMain`: executing already-queued work items and pumping messages. Outbound event delivery and pipe writes happen on threads that observe the queues populated from the STA, never on the STA itself.
|
||||
|
||||
## Shutdown Sequence
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace MxGateway.Contracts;
|
||||
/// </summary>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
public const uint GatewayProtocolVersion = 1;
|
||||
public const uint GatewayProtocolVersion = 2;
|
||||
|
||||
public const uint WorkerProtocolVersion = 1;
|
||||
|
||||
|
||||
@@ -25,54 +25,64 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
byte[] descriptorData = global::System.Convert.FromBase64String(
|
||||
string.Concat(
|
||||
"ChdnYWxheHlfcmVwb3NpdG9yeS5wcm90bxIUZ2FsYXh5X3JlcG9zaXRvcnku",
|
||||
"djEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJvdG8iFwoVVGVzdENv",
|
||||
"bm5lY3Rpb25SZXF1ZXN0IiEKE1Rlc3RDb25uZWN0aW9uUmVwbHkSCgoCb2sY",
|
||||
"ASABKAgiGgoYR2V0TGFzdERlcGxveVRpbWVSZXF1ZXN0ImIKFkdldExhc3RE",
|
||||
"ZXBsb3lUaW1lUmVwbHkSDwoHcHJlc2VudBgBIAEoCBI3ChN0aW1lX29mX2xh",
|
||||
"c3RfZGVwbG95GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCIa",
|
||||
"ChhEaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3QiTQoWRGlzY292ZXJIaWVyYXJj",
|
||||
"aHlSZXBseRIzCgdvYmplY3RzGAEgAygLMiIuZ2FsYXh5X3JlcG9zaXRvcnku",
|
||||
"djEuR2FsYXh5T2JqZWN0IlUKGFdhdGNoRGVwbG95RXZlbnRzUmVxdWVzdBI5",
|
||||
"ChVsYXN0X3NlZW5fZGVwbG95X3RpbWUYASABKAsyGi5nb29nbGUucHJvdG9i",
|
||||
"dWYuVGltZXN0YW1wIt0BCgtEZXBsb3lFdmVudBIQCghzZXF1ZW5jZRgBIAEo",
|
||||
"BBIvCgtvYnNlcnZlZF9hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
|
||||
"c3RhbXASNwoTdGltZV9vZl9sYXN0X2RlcGxveRgDIAEoCzIaLmdvb2dsZS5w",
|
||||
"cm90b2J1Zi5UaW1lc3RhbXASIwobdGltZV9vZl9sYXN0X2RlcGxveV9wcmVz",
|
||||
"ZW50GAQgASgIEhQKDG9iamVjdF9jb3VudBgFIAEoBRIXCg9hdHRyaWJ1dGVf",
|
||||
"Y291bnQYBiABKAUikwIKDEdhbGF4eU9iamVjdBISCgpnb2JqZWN0X2lkGAEg",
|
||||
"ASgFEhAKCHRhZ19uYW1lGAIgASgJEhYKDmNvbnRhaW5lZF9uYW1lGAMgASgJ",
|
||||
"EhMKC2Jyb3dzZV9uYW1lGAQgASgJEhkKEXBhcmVudF9nb2JqZWN0X2lkGAUg",
|
||||
"ASgFEg8KB2lzX2FyZWEYBiABKAgSEwoLY2F0ZWdvcnlfaWQYByABKAUSHAoU",
|
||||
"aG9zdGVkX2J5X2dvYmplY3RfaWQYCCABKAUSFgoOdGVtcGxhdGVfY2hhaW4Y",
|
||||
"CSADKAkSOQoKYXR0cmlidXRlcxgKIAMoCzIlLmdhbGF4eV9yZXBvc2l0b3J5",
|
||||
"LnYxLkdhbGF4eUF0dHJpYnV0ZSKoAgoPR2FsYXh5QXR0cmlidXRlEhYKDmF0",
|
||||
"dHJpYnV0ZV9uYW1lGAEgASgJEhoKEmZ1bGxfdGFnX3JlZmVyZW5jZRgCIAEo",
|
||||
"CRIUCgxteF9kYXRhX3R5cGUYAyABKAUSFgoOZGF0YV90eXBlX25hbWUYBCAB",
|
||||
"KAkSEAoIaXNfYXJyYXkYBSABKAgSFwoPYXJyYXlfZGltZW5zaW9uGAYgASgF",
|
||||
"Eh8KF2FycmF5X2RpbWVuc2lvbl9wcmVzZW50GAcgASgIEh0KFW14X2F0dHJp",
|
||||
"YnV0ZV9jYXRlZ29yeRgIIAEoBRIfChdzZWN1cml0eV9jbGFzc2lmaWNhdGlv",
|
||||
"bhgJIAEoBRIVCg1pc19oaXN0b3JpemVkGAogASgIEhAKCGlzX2FsYXJtGAsg",
|
||||
"ASgIMswDChBHYWxheHlSZXBvc2l0b3J5EmgKDlRlc3RDb25uZWN0aW9uEisu",
|
||||
"Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXF1ZXN0Giku",
|
||||
"Z2FsYXh5X3JlcG9zaXRvcnkudjEuVGVzdENvbm5lY3Rpb25SZXBseRJxChFH",
|
||||
"ZXRMYXN0RGVwbG95VGltZRIuLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdldExh",
|
||||
"c3REZXBsb3lUaW1lUmVxdWVzdBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkdl",
|
||||
"dExhc3REZXBsb3lUaW1lUmVwbHkScQoRRGlzY292ZXJIaWVyYXJjaHkSLi5n",
|
||||
"YWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcXVlc3Qa",
|
||||
"LC5nYWxheHlfcmVwb3NpdG9yeS52MS5EaXNjb3ZlckhpZXJhcmNoeVJlcGx5",
|
||||
"EmgKEVdhdGNoRGVwbG95RXZlbnRzEi4uZ2FsYXh5X3JlcG9zaXRvcnkudjEu",
|
||||
"V2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0GiEuZ2FsYXh5X3JlcG9zaXRvcnku",
|
||||
"djEuRGVwbG95RXZlbnQwAUIjqgIgTXhHYXRld2F5LkNvbnRyYWN0cy5Qcm90",
|
||||
"by5HYWxheHliBnByb3RvMw=="));
|
||||
"djEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJvdG8aHmdvb2dsZS9w",
|
||||
"cm90b2J1Zi93cmFwcGVycy5wcm90byIXChVUZXN0Q29ubmVjdGlvblJlcXVl",
|
||||
"c3QiIQoTVGVzdENvbm5lY3Rpb25SZXBseRIKCgJvaxgBIAEoCCIaChhHZXRM",
|
||||
"YXN0RGVwbG95VGltZVJlcXVlc3QiYgoWR2V0TGFzdERlcGxveVRpbWVSZXBs",
|
||||
"eRIPCgdwcmVzZW50GAEgASgIEjcKE3RpbWVfb2ZfbGFzdF9kZXBsb3kYAiAB",
|
||||
"KAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIocDChhEaXNjb3Zlckhp",
|
||||
"ZXJhcmNoeVJlcXVlc3QSEQoJcGFnZV9zaXplGAEgASgFEhIKCnBhZ2VfdG9r",
|
||||
"ZW4YAiABKAkSGQoPcm9vdF9nb2JqZWN0X2lkGAMgASgFSAASFwoNcm9vdF90",
|
||||
"YWdfbmFtZRgEIAEoCUgAEh0KE3Jvb3RfY29udGFpbmVkX3BhdGgYBSABKAlI",
|
||||
"ABIuCgltYXhfZGVwdGgYBiABKAsyGy5nb29nbGUucHJvdG9idWYuSW50MzJW",
|
||||
"YWx1ZRIUCgxjYXRlZ29yeV9pZHMYByADKAUSHwoXdGVtcGxhdGVfY2hhaW5f",
|
||||
"Y29udGFpbnMYCCADKAkSFQoNdGFnX25hbWVfZ2xvYhgJIAEoCRIfChJpbmNs",
|
||||
"dWRlX2F0dHJpYnV0ZXMYCiABKAhIAYgBARIaChJhbGFybV9iZWFyaW5nX29u",
|
||||
"bHkYCyABKAgSFwoPaGlzdG9yaXplZF9vbmx5GAwgASgIQgYKBHJvb3RCFQoT",
|
||||
"X2luY2x1ZGVfYXR0cmlidXRlcyKCAQoWRGlzY292ZXJIaWVyYXJjaHlSZXBs",
|
||||
"eRIzCgdvYmplY3RzGAEgAygLMiIuZ2FsYXh5X3JlcG9zaXRvcnkudjEuR2Fs",
|
||||
"YXh5T2JqZWN0EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRIaChJ0b3RhbF9v",
|
||||
"YmplY3RfY291bnQYAyABKAUiVQoYV2F0Y2hEZXBsb3lFdmVudHNSZXF1ZXN0",
|
||||
"EjkKFWxhc3Rfc2Vlbl9kZXBsb3lfdGltZRgBIAEoCzIaLmdvb2dsZS5wcm90",
|
||||
"b2J1Zi5UaW1lc3RhbXAi3QEKC0RlcGxveUV2ZW50EhAKCHNlcXVlbmNlGAEg",
|
||||
"ASgEEi8KC29ic2VydmVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp",
|
||||
"bWVzdGFtcBI3ChN0aW1lX29mX2xhc3RfZGVwbG95GAMgASgLMhouZ29vZ2xl",
|
||||
"LnByb3RvYnVmLlRpbWVzdGFtcBIjCht0aW1lX29mX2xhc3RfZGVwbG95X3By",
|
||||
"ZXNlbnQYBCABKAgSFAoMb2JqZWN0X2NvdW50GAUgASgFEhcKD2F0dHJpYnV0",
|
||||
"ZV9jb3VudBgGIAEoBSKTAgoMR2FsYXh5T2JqZWN0EhIKCmdvYmplY3RfaWQY",
|
||||
"ASABKAUSEAoIdGFnX25hbWUYAiABKAkSFgoOY29udGFpbmVkX25hbWUYAyAB",
|
||||
"KAkSEwoLYnJvd3NlX25hbWUYBCABKAkSGQoRcGFyZW50X2dvYmplY3RfaWQY",
|
||||
"BSABKAUSDwoHaXNfYXJlYRgGIAEoCBITCgtjYXRlZ29yeV9pZBgHIAEoBRIc",
|
||||
"ChRob3N0ZWRfYnlfZ29iamVjdF9pZBgIIAEoBRIWCg50ZW1wbGF0ZV9jaGFp",
|
||||
"bhgJIAMoCRI5CgphdHRyaWJ1dGVzGAogAygLMiUuZ2FsYXh5X3JlcG9zaXRv",
|
||||
"cnkudjEuR2FsYXh5QXR0cmlidXRlIqgCCg9HYWxheHlBdHRyaWJ1dGUSFgoO",
|
||||
"YXR0cmlidXRlX25hbWUYASABKAkSGgoSZnVsbF90YWdfcmVmZXJlbmNlGAIg",
|
||||
"ASgJEhQKDG14X2RhdGFfdHlwZRgDIAEoBRIWCg5kYXRhX3R5cGVfbmFtZRgE",
|
||||
"IAEoCRIQCghpc19hcnJheRgFIAEoCBIXCg9hcnJheV9kaW1lbnNpb24YBiAB",
|
||||
"KAUSHwoXYXJyYXlfZGltZW5zaW9uX3ByZXNlbnQYByABKAgSHQoVbXhfYXR0",
|
||||
"cmlidXRlX2NhdGVnb3J5GAggASgFEh8KF3NlY3VyaXR5X2NsYXNzaWZpY2F0",
|
||||
"aW9uGAkgASgFEhUKDWlzX2hpc3Rvcml6ZWQYCiABKAgSEAoIaXNfYWxhcm0Y",
|
||||
"CyABKAgyzAMKEEdhbGF4eVJlcG9zaXRvcnkSaAoOVGVzdENvbm5lY3Rpb24S",
|
||||
"Ky5nYWxheHlfcmVwb3NpdG9yeS52MS5UZXN0Q29ubmVjdGlvblJlcXVlc3Qa",
|
||||
"KS5nYWxheHlfcmVwb3NpdG9yeS52MS5UZXN0Q29ubmVjdGlvblJlcGx5EnEK",
|
||||
"EUdldExhc3REZXBsb3lUaW1lEi4uZ2FsYXh5X3JlcG9zaXRvcnkudjEuR2V0",
|
||||
"TGFzdERlcGxveVRpbWVSZXF1ZXN0GiwuZ2FsYXh5X3JlcG9zaXRvcnkudjEu",
|
||||
"R2V0TGFzdERlcGxveVRpbWVSZXBseRJxChFEaXNjb3ZlckhpZXJhcmNoeRIu",
|
||||
"LmdhbGF4eV9yZXBvc2l0b3J5LnYxLkRpc2NvdmVySGllcmFyY2h5UmVxdWVz",
|
||||
"dBosLmdhbGF4eV9yZXBvc2l0b3J5LnYxLkRpc2NvdmVySGllcmFyY2h5UmVw",
|
||||
"bHkSaAoRV2F0Y2hEZXBsb3lFdmVudHMSLi5nYWxheHlfcmVwb3NpdG9yeS52",
|
||||
"MS5XYXRjaERlcGxveUV2ZW50c1JlcXVlc3QaIS5nYWxheHlfcmVwb3NpdG9y",
|
||||
"eS52MS5EZXBsb3lFdmVudDABQiOqAiBNeEdhdGV3YXkuQ29udHJhY3RzLlBy",
|
||||
"b3RvLkdhbGF4eWIGcHJvdG8z"));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
|
||||
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, },
|
||||
new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] {
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser, null, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply), global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser, new[]{ "Ok" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser, null, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply), global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser, new[]{ "Present", "TimeOfLastDeploy" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, null, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser, new[]{ "PageSize", "PageToken", "RootGobjectId", "RootTagName", "RootContainedPath", "MaxDepth", "CategoryIds", "TemplateChainContains", "TagNameGlob", "IncludeAttributes", "AlarmBearingOnly", "HistorizedOnly" }, new[]{ "Root", "IncludeAttributes" }, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply), global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser, new[]{ "Objects", "NextPageToken", "TotalObjectCount" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest), global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser, new[]{ "LastSeenDeployTime" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.DeployEvent), global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser, new[]{ "Sequence", "ObservedAt", "TimeOfLastDeploy", "TimeOfLastDeployPresent", "ObjectCount", "AttributeCount" }, null, null, null, null),
|
||||
new pbr::GeneratedClrTypeInfo(typeof(global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject), global::MxGateway.Contracts.Proto.Galaxy.GalaxyObject.Parser, new[]{ "GobjectId", "TagName", "ContainedName", "BrowseName", "ParentGobjectId", "IsArea", "CategoryId", "HostedByGobjectId", "TemplateChain", "Attributes" }, null, null, null, null),
|
||||
@@ -855,6 +865,7 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
{
|
||||
private static readonly pb::MessageParser<DiscoverHierarchyRequest> _parser = new pb::MessageParser<DiscoverHierarchyRequest>(() => new DiscoverHierarchyRequest());
|
||||
private pb::UnknownFieldSet _unknownFields;
|
||||
private int _hasBits0;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public static pb::MessageParser<DiscoverHierarchyRequest> Parser { get { return _parser; } }
|
||||
@@ -882,6 +893,28 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public DiscoverHierarchyRequest(DiscoverHierarchyRequest other) : this() {
|
||||
_hasBits0 = other._hasBits0;
|
||||
pageSize_ = other.pageSize_;
|
||||
pageToken_ = other.pageToken_;
|
||||
MaxDepth = other.MaxDepth;
|
||||
categoryIds_ = other.categoryIds_.Clone();
|
||||
templateChainContains_ = other.templateChainContains_.Clone();
|
||||
tagNameGlob_ = other.tagNameGlob_;
|
||||
includeAttributes_ = other.includeAttributes_;
|
||||
alarmBearingOnly_ = other.alarmBearingOnly_;
|
||||
historizedOnly_ = other.historizedOnly_;
|
||||
switch (other.RootCase) {
|
||||
case RootOneofCase.RootGobjectId:
|
||||
RootGobjectId = other.RootGobjectId;
|
||||
break;
|
||||
case RootOneofCase.RootTagName:
|
||||
RootTagName = other.RootTagName;
|
||||
break;
|
||||
case RootOneofCase.RootContainedPath:
|
||||
RootContainedPath = other.RootContainedPath;
|
||||
break;
|
||||
}
|
||||
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -891,6 +924,258 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
return new DiscoverHierarchyRequest(this);
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "page_size" field.</summary>
|
||||
public const int PageSizeFieldNumber = 1;
|
||||
private int pageSize_;
|
||||
/// <summary>
|
||||
/// Maximum number of objects to return. The server applies its default when
|
||||
/// unset and rejects non-positive values.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int PageSize {
|
||||
get { return pageSize_; }
|
||||
set {
|
||||
pageSize_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "page_token" field.</summary>
|
||||
public const int PageTokenFieldNumber = 2;
|
||||
private string pageToken_ = "";
|
||||
/// <summary>
|
||||
/// Opaque token returned by a previous DiscoverHierarchy response.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string PageToken {
|
||||
get { return pageToken_; }
|
||||
set {
|
||||
pageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_gobject_id" field.</summary>
|
||||
public const int RootGobjectIdFieldNumber = 3;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int RootGobjectId {
|
||||
get { return HasRootGobjectId ? (int) root_ : 0; }
|
||||
set {
|
||||
root_ = value;
|
||||
rootCase_ = RootOneofCase.RootGobjectId;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_gobject_id" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootGobjectId {
|
||||
get { return rootCase_ == RootOneofCase.RootGobjectId; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_gobject_id" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootGobjectId() {
|
||||
if (HasRootGobjectId) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_tag_name" field.</summary>
|
||||
public const int RootTagNameFieldNumber = 4;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string RootTagName {
|
||||
get { return HasRootTagName ? (string) root_ : ""; }
|
||||
set {
|
||||
root_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
rootCase_ = RootOneofCase.RootTagName;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_tag_name" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootTagName {
|
||||
get { return rootCase_ == RootOneofCase.RootTagName; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_tag_name" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootTagName() {
|
||||
if (HasRootTagName) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "root_contained_path" field.</summary>
|
||||
public const int RootContainedPathFieldNumber = 5;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string RootContainedPath {
|
||||
get { return HasRootContainedPath ? (string) root_ : ""; }
|
||||
set {
|
||||
root_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
rootCase_ = RootOneofCase.RootContainedPath;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "root_contained_path" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasRootContainedPath {
|
||||
get { return rootCase_ == RootOneofCase.RootContainedPath; }
|
||||
}
|
||||
/// <summary> Clears the value of the oneof if it's currently set to "root_contained_path" </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRootContainedPath() {
|
||||
if (HasRootContainedPath) {
|
||||
ClearRoot();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "max_depth" field.</summary>
|
||||
public const int MaxDepthFieldNumber = 6;
|
||||
private static readonly pb::FieldCodec<int?> _single_maxDepth_codec = pb::FieldCodec.ForStructWrapper<int>(50);
|
||||
private int? maxDepth_;
|
||||
/// <summary>
|
||||
/// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||
/// Unset means unlimited depth.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int? MaxDepth {
|
||||
get { return maxDepth_; }
|
||||
set {
|
||||
maxDepth_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Field number for the "category_ids" field.</summary>
|
||||
public const int CategoryIdsFieldNumber = 7;
|
||||
private static readonly pb::FieldCodec<int> _repeated_categoryIds_codec
|
||||
= pb::FieldCodec.ForInt32(58);
|
||||
private readonly pbc::RepeatedField<int> categoryIds_ = new pbc::RepeatedField<int>();
|
||||
/// <summary>
|
||||
/// Optional object category id filters.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<int> CategoryIds {
|
||||
get { return categoryIds_; }
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "template_chain_contains" field.</summary>
|
||||
public const int TemplateChainContainsFieldNumber = 8;
|
||||
private static readonly pb::FieldCodec<string> _repeated_templateChainContains_codec
|
||||
= pb::FieldCodec.ForString(66);
|
||||
private readonly pbc::RepeatedField<string> templateChainContains_ = new pbc::RepeatedField<string>();
|
||||
/// <summary>
|
||||
/// Optional case-insensitive substring filters against template names.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public pbc::RepeatedField<string> TemplateChainContains {
|
||||
get { return templateChainContains_; }
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "tag_name_glob" field.</summary>
|
||||
public const int TagNameGlobFieldNumber = 9;
|
||||
private string tagNameGlob_ = "";
|
||||
/// <summary>
|
||||
/// Optional anchored, case-insensitive glob over object tag_name.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string TagNameGlob {
|
||||
get { return tagNameGlob_; }
|
||||
set {
|
||||
tagNameGlob_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "include_attributes" field.</summary>
|
||||
public const int IncludeAttributesFieldNumber = 10;
|
||||
private readonly static bool IncludeAttributesDefaultValue = false;
|
||||
|
||||
private bool includeAttributes_;
|
||||
/// <summary>
|
||||
/// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool IncludeAttributes {
|
||||
get { if ((_hasBits0 & 1) != 0) { return includeAttributes_; } else { return IncludeAttributesDefaultValue; } }
|
||||
set {
|
||||
_hasBits0 |= 1;
|
||||
includeAttributes_ = value;
|
||||
}
|
||||
}
|
||||
/// <summary>Gets whether the "include_attributes" field is set</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HasIncludeAttributes {
|
||||
get { return (_hasBits0 & 1) != 0; }
|
||||
}
|
||||
/// <summary>Clears the value of the "include_attributes" field</summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearIncludeAttributes() {
|
||||
_hasBits0 &= ~1;
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "alarm_bearing_only" field.</summary>
|
||||
public const int AlarmBearingOnlyFieldNumber = 11;
|
||||
private bool alarmBearingOnly_;
|
||||
/// <summary>
|
||||
/// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool AlarmBearingOnly {
|
||||
get { return alarmBearingOnly_; }
|
||||
set {
|
||||
alarmBearingOnly_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "historized_only" field.</summary>
|
||||
public const int HistorizedOnlyFieldNumber = 12;
|
||||
private bool historizedOnly_;
|
||||
/// <summary>
|
||||
/// Optional. Return only objects with at least one historized attribute.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public bool HistorizedOnly {
|
||||
get { return historizedOnly_; }
|
||||
set {
|
||||
historizedOnly_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
private object root_;
|
||||
/// <summary>Enum of possible cases for the "root" oneof.</summary>
|
||||
public enum RootOneofCase {
|
||||
None = 0,
|
||||
RootGobjectId = 3,
|
||||
RootTagName = 4,
|
||||
RootContainedPath = 5,
|
||||
}
|
||||
private RootOneofCase rootCase_ = RootOneofCase.None;
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public RootOneofCase RootCase {
|
||||
get { return rootCase_; }
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public void ClearRoot() {
|
||||
rootCase_ = RootOneofCase.None;
|
||||
root_ = null;
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -906,6 +1191,19 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
if (ReferenceEquals(other, this)) {
|
||||
return true;
|
||||
}
|
||||
if (PageSize != other.PageSize) return false;
|
||||
if (PageToken != other.PageToken) return false;
|
||||
if (RootGobjectId != other.RootGobjectId) return false;
|
||||
if (RootTagName != other.RootTagName) return false;
|
||||
if (RootContainedPath != other.RootContainedPath) return false;
|
||||
if (MaxDepth != other.MaxDepth) return false;
|
||||
if(!categoryIds_.Equals(other.categoryIds_)) return false;
|
||||
if(!templateChainContains_.Equals(other.templateChainContains_)) return false;
|
||||
if (TagNameGlob != other.TagNameGlob) return false;
|
||||
if (IncludeAttributes != other.IncludeAttributes) return false;
|
||||
if (AlarmBearingOnly != other.AlarmBearingOnly) return false;
|
||||
if (HistorizedOnly != other.HistorizedOnly) return false;
|
||||
if (RootCase != other.RootCase) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -913,6 +1211,19 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override int GetHashCode() {
|
||||
int hash = 1;
|
||||
if (PageSize != 0) hash ^= PageSize.GetHashCode();
|
||||
if (PageToken.Length != 0) hash ^= PageToken.GetHashCode();
|
||||
if (HasRootGobjectId) hash ^= RootGobjectId.GetHashCode();
|
||||
if (HasRootTagName) hash ^= RootTagName.GetHashCode();
|
||||
if (HasRootContainedPath) hash ^= RootContainedPath.GetHashCode();
|
||||
if (maxDepth_ != null) hash ^= MaxDepth.GetHashCode();
|
||||
hash ^= categoryIds_.GetHashCode();
|
||||
hash ^= templateChainContains_.GetHashCode();
|
||||
if (TagNameGlob.Length != 0) hash ^= TagNameGlob.GetHashCode();
|
||||
if (HasIncludeAttributes) hash ^= IncludeAttributes.GetHashCode();
|
||||
if (AlarmBearingOnly != false) hash ^= AlarmBearingOnly.GetHashCode();
|
||||
if (HistorizedOnly != false) hash ^= HistorizedOnly.GetHashCode();
|
||||
hash ^= (int) rootCase_;
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -931,6 +1242,47 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
#if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE
|
||||
output.WriteRawMessage(this);
|
||||
#else
|
||||
if (PageSize != 0) {
|
||||
output.WriteRawTag(8);
|
||||
output.WriteInt32(PageSize);
|
||||
}
|
||||
if (PageToken.Length != 0) {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(PageToken);
|
||||
}
|
||||
if (HasRootGobjectId) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteInt32(RootGobjectId);
|
||||
}
|
||||
if (HasRootTagName) {
|
||||
output.WriteRawTag(34);
|
||||
output.WriteString(RootTagName);
|
||||
}
|
||||
if (HasRootContainedPath) {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteString(RootContainedPath);
|
||||
}
|
||||
if (maxDepth_ != null) {
|
||||
_single_maxDepth_codec.WriteTagAndValue(output, MaxDepth);
|
||||
}
|
||||
categoryIds_.WriteTo(output, _repeated_categoryIds_codec);
|
||||
templateChainContains_.WriteTo(output, _repeated_templateChainContains_codec);
|
||||
if (TagNameGlob.Length != 0) {
|
||||
output.WriteRawTag(74);
|
||||
output.WriteString(TagNameGlob);
|
||||
}
|
||||
if (HasIncludeAttributes) {
|
||||
output.WriteRawTag(80);
|
||||
output.WriteBool(IncludeAttributes);
|
||||
}
|
||||
if (AlarmBearingOnly != false) {
|
||||
output.WriteRawTag(88);
|
||||
output.WriteBool(AlarmBearingOnly);
|
||||
}
|
||||
if (HistorizedOnly != false) {
|
||||
output.WriteRawTag(96);
|
||||
output.WriteBool(HistorizedOnly);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -941,6 +1293,47 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
|
||||
if (PageSize != 0) {
|
||||
output.WriteRawTag(8);
|
||||
output.WriteInt32(PageSize);
|
||||
}
|
||||
if (PageToken.Length != 0) {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(PageToken);
|
||||
}
|
||||
if (HasRootGobjectId) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteInt32(RootGobjectId);
|
||||
}
|
||||
if (HasRootTagName) {
|
||||
output.WriteRawTag(34);
|
||||
output.WriteString(RootTagName);
|
||||
}
|
||||
if (HasRootContainedPath) {
|
||||
output.WriteRawTag(42);
|
||||
output.WriteString(RootContainedPath);
|
||||
}
|
||||
if (maxDepth_ != null) {
|
||||
_single_maxDepth_codec.WriteTagAndValue(ref output, MaxDepth);
|
||||
}
|
||||
categoryIds_.WriteTo(ref output, _repeated_categoryIds_codec);
|
||||
templateChainContains_.WriteTo(ref output, _repeated_templateChainContains_codec);
|
||||
if (TagNameGlob.Length != 0) {
|
||||
output.WriteRawTag(74);
|
||||
output.WriteString(TagNameGlob);
|
||||
}
|
||||
if (HasIncludeAttributes) {
|
||||
output.WriteRawTag(80);
|
||||
output.WriteBool(IncludeAttributes);
|
||||
}
|
||||
if (AlarmBearingOnly != false) {
|
||||
output.WriteRawTag(88);
|
||||
output.WriteBool(AlarmBearingOnly);
|
||||
}
|
||||
if (HistorizedOnly != false) {
|
||||
output.WriteRawTag(96);
|
||||
output.WriteBool(HistorizedOnly);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -951,6 +1344,38 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int CalculateSize() {
|
||||
int size = 0;
|
||||
if (PageSize != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeInt32Size(PageSize);
|
||||
}
|
||||
if (PageToken.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(PageToken);
|
||||
}
|
||||
if (HasRootGobjectId) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeInt32Size(RootGobjectId);
|
||||
}
|
||||
if (HasRootTagName) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(RootTagName);
|
||||
}
|
||||
if (HasRootContainedPath) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(RootContainedPath);
|
||||
}
|
||||
if (maxDepth_ != null) {
|
||||
size += _single_maxDepth_codec.CalculateSizeWithTag(MaxDepth);
|
||||
}
|
||||
size += categoryIds_.CalculateSize(_repeated_categoryIds_codec);
|
||||
size += templateChainContains_.CalculateSize(_repeated_templateChainContains_codec);
|
||||
if (TagNameGlob.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(TagNameGlob);
|
||||
}
|
||||
if (HasIncludeAttributes) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (AlarmBearingOnly != false) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (HistorizedOnly != false) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -963,6 +1388,43 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
if (other == null) {
|
||||
return;
|
||||
}
|
||||
if (other.PageSize != 0) {
|
||||
PageSize = other.PageSize;
|
||||
}
|
||||
if (other.PageToken.Length != 0) {
|
||||
PageToken = other.PageToken;
|
||||
}
|
||||
if (other.maxDepth_ != null) {
|
||||
if (maxDepth_ == null || other.MaxDepth != 0) {
|
||||
MaxDepth = other.MaxDepth;
|
||||
}
|
||||
}
|
||||
categoryIds_.Add(other.categoryIds_);
|
||||
templateChainContains_.Add(other.templateChainContains_);
|
||||
if (other.TagNameGlob.Length != 0) {
|
||||
TagNameGlob = other.TagNameGlob;
|
||||
}
|
||||
if (other.HasIncludeAttributes) {
|
||||
IncludeAttributes = other.IncludeAttributes;
|
||||
}
|
||||
if (other.AlarmBearingOnly != false) {
|
||||
AlarmBearingOnly = other.AlarmBearingOnly;
|
||||
}
|
||||
if (other.HistorizedOnly != false) {
|
||||
HistorizedOnly = other.HistorizedOnly;
|
||||
}
|
||||
switch (other.RootCase) {
|
||||
case RootOneofCase.RootGobjectId:
|
||||
RootGobjectId = other.RootGobjectId;
|
||||
break;
|
||||
case RootOneofCase.RootTagName:
|
||||
RootTagName = other.RootTagName;
|
||||
break;
|
||||
case RootOneofCase.RootContainedPath:
|
||||
RootContainedPath = other.RootContainedPath;
|
||||
break;
|
||||
}
|
||||
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -982,6 +1444,58 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
default:
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
|
||||
break;
|
||||
case 8: {
|
||||
PageSize = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
case 18: {
|
||||
PageToken = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
RootGobjectId = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
case 34: {
|
||||
RootTagName = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 42: {
|
||||
RootContainedPath = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 50: {
|
||||
int? value = _single_maxDepth_codec.Read(input);
|
||||
if (maxDepth_ == null || value != 0) {
|
||||
MaxDepth = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 58:
|
||||
case 56: {
|
||||
categoryIds_.AddEntriesFrom(input, _repeated_categoryIds_codec);
|
||||
break;
|
||||
}
|
||||
case 66: {
|
||||
templateChainContains_.AddEntriesFrom(input, _repeated_templateChainContains_codec);
|
||||
break;
|
||||
}
|
||||
case 74: {
|
||||
TagNameGlob = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 80: {
|
||||
IncludeAttributes = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
case 88: {
|
||||
AlarmBearingOnly = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
case 96: {
|
||||
HistorizedOnly = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1001,6 +1515,58 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
default:
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input);
|
||||
break;
|
||||
case 8: {
|
||||
PageSize = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
case 18: {
|
||||
PageToken = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
RootGobjectId = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
case 34: {
|
||||
RootTagName = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 42: {
|
||||
RootContainedPath = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 50: {
|
||||
int? value = _single_maxDepth_codec.Read(ref input);
|
||||
if (maxDepth_ == null || value != 0) {
|
||||
MaxDepth = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 58:
|
||||
case 56: {
|
||||
categoryIds_.AddEntriesFrom(ref input, _repeated_categoryIds_codec);
|
||||
break;
|
||||
}
|
||||
case 66: {
|
||||
templateChainContains_.AddEntriesFrom(ref input, _repeated_templateChainContains_codec);
|
||||
break;
|
||||
}
|
||||
case 74: {
|
||||
TagNameGlob = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 80: {
|
||||
IncludeAttributes = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
case 88: {
|
||||
AlarmBearingOnly = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
case 96: {
|
||||
HistorizedOnly = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1044,6 +1610,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public DiscoverHierarchyReply(DiscoverHierarchyReply other) : this() {
|
||||
objects_ = other.objects_.Clone();
|
||||
nextPageToken_ = other.nextPageToken_;
|
||||
totalObjectCount_ = other.totalObjectCount_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1064,6 +1632,36 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
get { return objects_; }
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "next_page_token" field.</summary>
|
||||
public const int NextPageTokenFieldNumber = 2;
|
||||
private string nextPageToken_ = "";
|
||||
/// <summary>
|
||||
/// Non-empty when another page is available.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public string NextPageToken {
|
||||
get { return nextPageToken_; }
|
||||
set {
|
||||
nextPageToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Field number for the "total_object_count" field.</summary>
|
||||
public const int TotalObjectCountFieldNumber = 3;
|
||||
private int totalObjectCount_;
|
||||
/// <summary>
|
||||
/// Total number of objects in the cached hierarchy at the time of the call.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public int TotalObjectCount {
|
||||
get { return totalObjectCount_; }
|
||||
set {
|
||||
totalObjectCount_ = value;
|
||||
}
|
||||
}
|
||||
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -1080,6 +1678,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
return true;
|
||||
}
|
||||
if(!objects_.Equals(other.objects_)) return false;
|
||||
if (NextPageToken != other.NextPageToken) return false;
|
||||
if (TotalObjectCount != other.TotalObjectCount) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1088,6 +1688,8 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
public override int GetHashCode() {
|
||||
int hash = 1;
|
||||
hash ^= objects_.GetHashCode();
|
||||
if (NextPageToken.Length != 0) hash ^= NextPageToken.GetHashCode();
|
||||
if (TotalObjectCount != 0) hash ^= TotalObjectCount.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -1107,6 +1709,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
output.WriteRawMessage(this);
|
||||
#else
|
||||
objects_.WriteTo(output, _repeated_objects_codec);
|
||||
if (NextPageToken.Length != 0) {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(NextPageToken);
|
||||
}
|
||||
if (TotalObjectCount != 0) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteInt32(TotalObjectCount);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -1118,6 +1728,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
|
||||
objects_.WriteTo(ref output, _repeated_objects_codec);
|
||||
if (NextPageToken.Length != 0) {
|
||||
output.WriteRawTag(18);
|
||||
output.WriteString(NextPageToken);
|
||||
}
|
||||
if (TotalObjectCount != 0) {
|
||||
output.WriteRawTag(24);
|
||||
output.WriteInt32(TotalObjectCount);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -1129,6 +1747,12 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
public int CalculateSize() {
|
||||
int size = 0;
|
||||
size += objects_.CalculateSize(_repeated_objects_codec);
|
||||
if (NextPageToken.Length != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeStringSize(NextPageToken);
|
||||
}
|
||||
if (TotalObjectCount != 0) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeInt32Size(TotalObjectCount);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -1142,6 +1766,12 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
return;
|
||||
}
|
||||
objects_.Add(other.objects_);
|
||||
if (other.NextPageToken.Length != 0) {
|
||||
NextPageToken = other.NextPageToken;
|
||||
}
|
||||
if (other.TotalObjectCount != 0) {
|
||||
TotalObjectCount = other.TotalObjectCount;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -1165,6 +1795,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
objects_.AddEntriesFrom(input, _repeated_objects_codec);
|
||||
break;
|
||||
}
|
||||
case 18: {
|
||||
NextPageToken = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
TotalObjectCount = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1188,6 +1826,14 @@ namespace MxGateway.Contracts.Proto.Galaxy {
|
||||
objects_.AddEntriesFrom(ref input, _repeated_objects_codec);
|
||||
break;
|
||||
}
|
||||
case 18: {
|
||||
NextPageToken = input.ReadString();
|
||||
break;
|
||||
}
|
||||
case 24: {
|
||||
TotalObjectCount = input.ReadInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package galaxy_repository.v1;
|
||||
option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "google/protobuf/wrappers.proto";
|
||||
|
||||
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
|
||||
// database). Lets clients enumerate the deployed object hierarchy and each
|
||||
@@ -37,10 +38,42 @@ message GetLastDeployTimeReply {
|
||||
google.protobuf.Timestamp time_of_last_deploy = 2;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyRequest {}
|
||||
message DiscoverHierarchyRequest {
|
||||
// Maximum number of objects to return. The server applies its default when
|
||||
// unset and rejects non-positive values.
|
||||
int32 page_size = 1;
|
||||
// Opaque token returned by a previous DiscoverHierarchy response.
|
||||
string page_token = 2;
|
||||
// Optional. When set, return only this object and its descendants.
|
||||
// Empty = full hierarchy.
|
||||
oneof root {
|
||||
int32 root_gobject_id = 3;
|
||||
string root_tag_name = 4;
|
||||
string root_contained_path = 5;
|
||||
}
|
||||
// Optional. Cap on descendant depth from root. Zero returns only the root.
|
||||
// Unset means unlimited depth.
|
||||
google.protobuf.Int32Value max_depth = 6;
|
||||
// Optional object category id filters.
|
||||
repeated int32 category_ids = 7;
|
||||
// Optional case-insensitive substring filters against template names.
|
||||
repeated string template_chain_contains = 8;
|
||||
// Optional anchored, case-insensitive glob over object tag_name.
|
||||
string tag_name_glob = 9;
|
||||
// Optional. Unset or true includes attributes. False returns object skeletons.
|
||||
optional bool include_attributes = 10;
|
||||
// Optional. Return only objects with at least one alarm-bearing attribute.
|
||||
bool alarm_bearing_only = 11;
|
||||
// Optional. Return only objects with at least one historized attribute.
|
||||
bool historized_only = 12;
|
||||
}
|
||||
|
||||
message DiscoverHierarchyReply {
|
||||
repeated GalaxyObject objects = 1;
|
||||
// Non-empty when another page is available.
|
||||
string next_page_token = 2;
|
||||
// Total number of objects in the cached hierarchy at the time of the call.
|
||||
int32 total_object_count = 3;
|
||||
}
|
||||
|
||||
message WatchDeployEventsRequest {
|
||||
|
||||
@@ -9,6 +9,7 @@ using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Grpc;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -260,6 +261,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
Service = new MxAccessGatewayService(
|
||||
sessionManager,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
mapper,
|
||||
eventStreamService,
|
||||
@@ -592,4 +594,33 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveGatewayConfiguration(
|
||||
EffectiveAuthenticationConfiguration Authentication,
|
||||
EffectiveLdapConfiguration Ldap,
|
||||
EffectiveWorkerConfiguration Worker,
|
||||
EffectiveSessionConfiguration Sessions,
|
||||
EffectiveEventConfiguration Events,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
|
||||
public sealed record EffectiveProtocolConfiguration(
|
||||
uint WorkerProtocolVersion,
|
||||
int MaxGrpcMessageBytes);
|
||||
|
||||
@@ -3,4 +3,7 @@ namespace MxGateway.Server.Configuration;
|
||||
public sealed record EffectiveSessionConfiguration(
|
||||
int DefaultCommandTimeoutSeconds,
|
||||
int MaxSessions,
|
||||
int MaxPendingCommandsPerSession,
|
||||
int DefaultLeaseSeconds,
|
||||
int LeaseSweepIntervalSeconds,
|
||||
bool AllowMultipleEventSubscribers);
|
||||
|
||||
@@ -19,6 +19,19 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
SqlitePath: value.Authentication.SqlitePath,
|
||||
PepperSecretName: RedactedValue,
|
||||
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
|
||||
Ldap: new EffectiveLdapConfiguration(
|
||||
Enabled: value.Ldap.Enabled,
|
||||
Server: value.Ldap.Server,
|
||||
Port: value.Ldap.Port,
|
||||
UseTls: value.Ldap.UseTls,
|
||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
||||
SearchBase: value.Ldap.SearchBase,
|
||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||
ServiceAccountPassword: RedactedValue,
|
||||
UserNameAttribute: value.Ldap.UserNameAttribute,
|
||||
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
|
||||
GroupAttribute: value.Ldap.GroupAttribute,
|
||||
RequiredGroup: value.Ldap.RequiredGroup),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
@@ -31,6 +44,9 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
Sessions: new EffectiveSessionConfiguration(
|
||||
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
|
||||
MaxSessions: value.Sessions.MaxSessions,
|
||||
MaxPendingCommandsPerSession: value.Sessions.MaxPendingCommandsPerSession,
|
||||
DefaultLeaseSeconds: value.Sessions.DefaultLeaseSeconds,
|
||||
LeaseSweepIntervalSeconds: value.Sessions.LeaseSweepIntervalSeconds,
|
||||
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
|
||||
Events: new EffectiveEventConfiguration(
|
||||
QueueCapacity: value.Events.QueueCapacity,
|
||||
@@ -44,6 +60,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion));
|
||||
Protocol: new EffectiveProtocolConfiguration(
|
||||
value.Protocol.WorkerProtocolVersion,
|
||||
value.Protocol.MaxGrpcMessageBytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class GatewayOptions
|
||||
/// </summary>
|
||||
public AuthenticationOptions Authentication { get; init; } = new();
|
||||
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets worker process configuration options.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
List<string> failures = [];
|
||||
|
||||
ValidateAuthentication(options.Authentication, failures);
|
||||
ValidateLdap(options.Ldap, failures);
|
||||
ValidateWorker(options.Worker, failures);
|
||||
ValidateSessions(options.Sessions, failures);
|
||||
ValidateEvents(options.Events, failures);
|
||||
@@ -55,6 +56,47 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountDn,
|
||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountPassword,
|
||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.UserNameAttribute,
|
||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.DisplayNameAttribute,
|
||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.GroupAttribute,
|
||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.RequiredGroup,
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
{
|
||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
||||
{
|
||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
||||
@@ -135,6 +177,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
options.MaxPendingCommandsPerSession,
|
||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.DefaultLeaseSeconds,
|
||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.LeaseSweepIntervalSeconds,
|
||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.AllowMultipleEventSubscribers)
|
||||
{
|
||||
@@ -185,6 +235,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||
}
|
||||
|
||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
||||
|
||||
@@ -11,4 +11,6 @@ public sealed class ProtocolOptions
|
||||
/// Gets or sets the worker protocol version.
|
||||
/// </summary>
|
||||
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
|
||||
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public sealed class SessionOptions
|
||||
/// </summary>
|
||||
public int MaxPendingCommandsPerSession { get; init; } = 128;
|
||||
|
||||
public int DefaultLeaseSeconds { get; init; } = 1800;
|
||||
|
||||
public int LeaseSweepIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether multiple event subscribers are allowed per session.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,14 +26,27 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="apikeys">API Keys</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>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="navbar-text">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -190,6 +190,8 @@ else
|
||||
|
||||
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
|
||||
|
||||
private string? GalaxyConnectionStringDisplay() =>
|
||||
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
|
||||
private string GalaxyConnectionStringDisplay()
|
||||
{
|
||||
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ else
|
||||
<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">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</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>
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
@using MxGateway.Server.Dashboard
|
||||
@using MxGateway.Server.Dashboard.Components.Layout
|
||||
@using MxGateway.Server.Dashboard.Components.Shared
|
||||
@using MxGateway.Server.Security.Authorization
|
||||
@using MxGateway.Server.Workers
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
|
||||
@@ -5,6 +5,7 @@ public static class DashboardAuthenticationDefaults
|
||||
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||
public const string ScopeClaimType = "scope";
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||
}
|
||||
|
||||
@@ -1,81 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthenticator(
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
IOptions<GatewayOptions> options) : IDashboardAuthenticator
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||
{
|
||||
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? apiKey,
|
||||
string? username,
|
||||
string? password,
|
||||
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))
|
||||
LdapOptions ldapOptions = options.Value.Ldap;
|
||||
if (!ldapOptions.Enabled
|
||||
|| string.IsNullOrWhiteSpace(username)
|
||||
|| string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
||||
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
|
||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string normalizedUsername = username.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
using LdapConnection connection = new();
|
||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? candidate = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Bind(candidate.Dn, password),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (authenticatedEntry is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
||||
?? normalizedUsername;
|
||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
||||
|
||||
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
|
||||
normalizedUsername,
|
||||
ldapOptions.RequiredGroup);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
groups));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
||||
normalizedUsername,
|
||||
ex.ResultCode);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string value)
|
||||
{
|
||||
StringBuilder builder = new(value.Length);
|
||||
foreach (char character in value)
|
||||
{
|
||||
builder.Append(character switch
|
||||
{
|
||||
'\\' => @"\5c",
|
||||
'*' => @"\2a",
|
||||
'(' => @"\28",
|
||||
')' => @"\29",
|
||||
'\0' => @"\00",
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
|
||||
{
|
||||
string normalizedRequiredGroup = requiredGroup.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
ExtractFirstRdnValue(normalizedGroup),
|
||||
normalizedRequiredGroup,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
return distinguishedName;
|
||||
}
|
||||
|
||||
int valueStart = equalsIndex + 1;
|
||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||
|
||||
return commaIndex > valueStart
|
||||
? distinguishedName[valueStart..commaIndex]
|
||||
: distinguishedName[valueStart..];
|
||||
}
|
||||
|
||||
private static Task BindServiceAccountAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(
|
||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<LdapEntry?> SearchUserAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
string username,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
ILdapSearchResults results = await Task.Run(
|
||||
() => connection.Search(
|
||||
ldapOptions.SearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attrs: null,
|
||||
typesOnly: false),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
||||
LdapEntry? entry = null;
|
||||
while (results.HasMore())
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
LdapEntry next = results.Next();
|
||||
if (entry is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entry = next;
|
||||
}
|
||||
|
||||
if (options.Value.Dashboard.RequireAdminScope
|
||||
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static string FormatAuthorizationHeader(string apiKey)
|
||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
string trimmedApiKey = apiKey.Trim();
|
||||
|
||||
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmedApiKey
|
||||
: $"Bearer {trimmedApiKey}";
|
||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
|
||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
||||
{
|
||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
||||
return attribute?.StringValueArray ?? [];
|
||||
}
|
||||
|
||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return entry.GetAttribute(attributeName)
|
||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string username,
|
||||
string displayName,
|
||||
IEnumerable<string> groups)
|
||||
{
|
||||
List<Claim> claims =
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
|
||||
new Claim(ClaimTypes.Name, identity.DisplayName),
|
||||
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
|
||||
new Claim(ClaimTypes.NameIdentifier, username),
|
||||
new Claim(ClaimTypes.Name, displayName)
|
||||
];
|
||||
|
||||
claims.AddRange(identity.Scopes.Select(scope => new Claim(
|
||||
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||
scope)));
|
||||
claims.AddRange(groups.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
DashboardAuthenticationDefaults.ScopeClaimType);
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType);
|
||||
|
||||
return new ClaimsPrincipal(claimsIdentity);
|
||||
}
|
||||
|
||||
@@ -43,18 +43,17 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
dashboard.MapPost(
|
||||
"/logout",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogout");
|
||||
|
||||
dashboard.MapGet("/denied", () => Results.Content(
|
||||
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
|
||||
"text/html"))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardAccessDenied");
|
||||
|
||||
dashboard.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
@@ -89,7 +88,10 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
pathBase);
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator
|
||||
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
|
||||
.AuthenticateAsync(
|
||||
form["username"].ToString(),
|
||||
form["password"].ToString(),
|
||||
httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Succeeded || result.Principal is null)
|
||||
@@ -131,7 +133,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
||||
? string.Empty
|
||||
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
|
||||
string body = $"""
|
||||
<section class="dashboard-login">
|
||||
@@ -141,8 +143,12 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
<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" />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</div>
|
||||
|
||||
@@ -2,101 +2,11 @@ using MxGateway.Server.Galaxy;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
|
||||
/// <see cref="DashboardGalaxySummary"/> for the Blazor pages. Top-templates and
|
||||
/// per-category breakdowns are computed here rather than stored on the cache so the
|
||||
/// Galaxy namespace stays free of dashboard-presentation concepts.
|
||||
/// </summary>
|
||||
/// <summary>Projects Galaxy Repository cache entries to dashboard presentation format.</summary>
|
||||
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
|
||||
internal static class DashboardGalaxyProjector
|
||||
{
|
||||
private const int TopTemplatesLimit = 10;
|
||||
|
||||
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
|
||||
{
|
||||
[1] = "WinPlatform",
|
||||
[3] = "AppEngine",
|
||||
[4] = "InTouchViewApp",
|
||||
[10] = "UserDefined",
|
||||
[11] = "FieldReference",
|
||||
[13] = "Area",
|
||||
[17] = "DIObject",
|
||||
[24] = "DDESuiteLinkClient",
|
||||
[26] = "OPCClient",
|
||||
};
|
||||
|
||||
/// <summary>Projects a Galaxy Repository cache entry to a dashboard summary.</summary>
|
||||
/// <param name="entry">Galaxy cache entry to project.</param>
|
||||
/// <returns>Dashboard-formatted Galaxy summary.</returns>
|
||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
DashboardGalaxyStatus status = entry.Status switch
|
||||
{
|
||||
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||
_ => DashboardGalaxyStatus.Unknown,
|
||||
};
|
||||
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
|
||||
if (entry.Hierarchy.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyHierarchyRow row in entry.Hierarchy)
|
||||
{
|
||||
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
||||
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
||||
|
||||
if (row.TemplateChain.Count > 0)
|
||||
{
|
||||
string immediate = row.TemplateChain[0];
|
||||
if (!string.IsNullOrWhiteSpace(immediate))
|
||||
{
|
||||
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||
templateUsage[immediate] = templateCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topTemplates = templateUsage
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(TopTemplatesLimit)
|
||||
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
||||
.ToArray();
|
||||
|
||||
objectCategories = objectsByCategory
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key)
|
||||
.Select(entry => new DashboardGalaxyCategoryCount(
|
||||
entry.Key,
|
||||
CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}",
|
||||
entry.Value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DashboardGalaxySummary(
|
||||
Status: status,
|
||||
LastQueriedAt: entry.LastQueriedAt,
|
||||
LastSuccessAt: entry.LastSuccessAt,
|
||||
LastDeployTime: entry.LastDeployTime,
|
||||
LastError: entry.LastError,
|
||||
ObjectCount: entry.ObjectCount,
|
||||
AreaCount: entry.AreaCount,
|
||||
AttributeCount: entry.AttributeCount,
|
||||
HistorizedAttributeCount: entry.HistorizedAttributeCount,
|
||||
AlarmAttributeCount: entry.AlarmAttributeCount,
|
||||
TopTemplates: topTemplates,
|
||||
ObjectCategories: objectCategories);
|
||||
return entry.DashboardSummary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ public static class DashboardServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
@@ -12,5 +12,6 @@ public sealed record DashboardSnapshot(
|
||||
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
|
||||
EffectiveGatewayConfiguration Configuration,
|
||||
DashboardGalaxySummary Galaxy);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
@@ -16,11 +19,16 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
private readonly GatewayMetrics _metrics;
|
||||
private readonly IGatewayConfigurationProvider _configurationProvider;
|
||||
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
|
||||
private readonly IApiKeyAdminStore _apiKeyAdminStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _gatewayStartedAt;
|
||||
private readonly TimeSpan _snapshotInterval;
|
||||
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
|
||||
private readonly int _recentFaultLimit;
|
||||
private readonly int _recentSessionLimit;
|
||||
private readonly ILogger<DashboardSnapshotService> _logger;
|
||||
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
|
||||
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
|
||||
|
||||
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
|
||||
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
|
||||
@@ -34,13 +42,16 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
GatewayMetrics metrics,
|
||||
IGatewayConfigurationProvider configurationProvider,
|
||||
IGalaxyHierarchyCache galaxyHierarchyCache,
|
||||
IApiKeyAdminStore apiKeyAdminStore,
|
||||
IOptions<GatewayOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<DashboardSnapshotService>? logger = null)
|
||||
{
|
||||
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
|
||||
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
|
||||
_apiKeyAdminStore = apiKeyAdminStore ?? throw new ArgumentNullException(nameof(apiKeyAdminStore));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
@@ -48,6 +59,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,6 +92,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
Workers: workerSummaries,
|
||||
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||
ApiKeys: Volatile.Read(ref _apiKeySummaries),
|
||||
Configuration: _configurationProvider.GetEffectiveConfiguration(),
|
||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||
}
|
||||
@@ -97,6 +110,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
|
||||
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||
@@ -117,6 +131,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -208,6 +223,51 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
|
||||
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
|
||||
.ConfigureAwait(false))
|
||||
.Select(key => new DashboardApiKeySummary(
|
||||
KeyId: key.KeyId,
|
||||
DisplayName: key.DisplayName,
|
||||
Scopes: key.Scopes,
|
||||
Constraints: key.Constraints,
|
||||
CreatedUtc: key.CreatedUtc,
|
||||
LastUsedUtc: key.LastUsedUtc,
|
||||
RevokedUtc: key.RevokedUtc))
|
||||
.ToArray();
|
||||
|
||||
Volatile.Write(ref _apiKeySummaries, summaries);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Timed out refreshing dashboard API key summaries after {Timeout}.",
|
||||
_apiKeySummaryRefreshTimeout);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_apiKeySummaryRefreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFault(GatewaySession session)
|
||||
{
|
||||
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
|
||||
/// <param name="apiKey">The API key to authenticate.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? apiKey,
|
||||
string? username,
|
||||
string? password,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Grpc;
|
||||
|
||||
namespace MxGateway.Server.Galaxy;
|
||||
@@ -49,7 +50,16 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
{
|
||||
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||
return projected == snapshot.Status ? snapshot : snapshot with { Status = projected };
|
||||
return projected == snapshot.Status
|
||||
? snapshot
|
||||
: snapshot with
|
||||
{
|
||||
Status = projected,
|
||||
DashboardSummary = snapshot.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(projected),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +111,14 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastError = null,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastDeployTime = deployTime,
|
||||
LastError = null,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, refreshed);
|
||||
_firstLoad.TrySetResult();
|
||||
@@ -113,11 +131,24 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
|
||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
|
||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||
|
||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||
int historized = attributes.Count(row => row.IsHistorized);
|
||||
int alarms = attributes.Count(row => row.IsAlarm);
|
||||
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
|
||||
status: GalaxyCacheStatus.Healthy,
|
||||
lastQueriedAt: queriedAt,
|
||||
lastSuccessAt: queriedAt,
|
||||
lastDeployTime: deployTime,
|
||||
lastError: null,
|
||||
hierarchy: hierarchy,
|
||||
objectCount: hierarchy.Count,
|
||||
areaCount: areaCount,
|
||||
attributeCount: attributes.Count,
|
||||
historizedAttributeCount: historized,
|
||||
alarmAttributeCount: alarms);
|
||||
|
||||
long nextSequence = previous.Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry next = new(
|
||||
@@ -127,9 +158,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
LastSuccessAt: queriedAt,
|
||||
LastDeployTime: deployTime,
|
||||
LastError: null,
|
||||
Hierarchy: hierarchy,
|
||||
Attributes: attributes,
|
||||
Reply: reply,
|
||||
Objects: objects,
|
||||
Index: index,
|
||||
DashboardSummary: dashboardSummary,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributes.Count,
|
||||
@@ -158,13 +189,19 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable),
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, failed);
|
||||
_firstLoad.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private static DiscoverHierarchyReply BuildReply(
|
||||
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
@@ -172,14 +209,110 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
DiscoverHierarchyReply reply = new();
|
||||
List<GalaxyObject> objects = new(hierarchy.Count);
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
}
|
||||
return reply;
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static DashboardGalaxySummary BuildDashboardSummary(
|
||||
GalaxyCacheStatus status,
|
||||
DateTimeOffset? lastQueriedAt,
|
||||
DateTimeOffset? lastSuccessAt,
|
||||
DateTimeOffset? lastDeployTime,
|
||||
string? lastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
int objectCount,
|
||||
int areaCount,
|
||||
int attributeCount,
|
||||
int historizedAttributeCount,
|
||||
int alarmAttributeCount)
|
||||
{
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
|
||||
if (hierarchy.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
||||
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
||||
|
||||
if (row.TemplateChain.Count > 0)
|
||||
{
|
||||
string immediate = row.TemplateChain[0];
|
||||
if (!string.IsNullOrWhiteSpace(immediate))
|
||||
{
|
||||
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||
templateUsage[immediate] = templateCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topTemplates = templateUsage
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
||||
.ToArray();
|
||||
|
||||
objectCategories = objectsByCategory
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key)
|
||||
.Select(entry => new DashboardGalaxyCategoryCount(
|
||||
entry.Key,
|
||||
ResolveCategoryName(entry.Key),
|
||||
entry.Value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DashboardGalaxySummary(
|
||||
Status: MapDashboardStatus(status),
|
||||
LastQueriedAt: lastQueriedAt,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastDeployTime: lastDeployTime,
|
||||
LastError: lastError,
|
||||
ObjectCount: objectCount,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributeCount,
|
||||
HistorizedAttributeCount: historizedAttributeCount,
|
||||
AlarmAttributeCount: alarmAttributeCount,
|
||||
TopTemplates: topTemplates,
|
||||
ObjectCategories: objectCategories);
|
||||
}
|
||||
|
||||
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
||||
{
|
||||
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||
_ => DashboardGalaxyStatus.Unknown,
|
||||
};
|
||||
|
||||
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "WinPlatform",
|
||||
3 => "AppEngine",
|
||||
4 => "InTouchViewApp",
|
||||
10 => "UserDefined",
|
||||
11 => "FieldReference",
|
||||
13 => "Area",
|
||||
17 => "DIObject",
|
||||
24 => "DDESuiteLinkClient",
|
||||
26 => "OPCClient",
|
||||
_ => $"Category {categoryId}",
|
||||
};
|
||||
|
||||
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
||||
{
|
||||
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Dashboard;
|
||||
|
||||
namespace MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of the Galaxy Repository browse data held by
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same instance —
|
||||
/// the materialized <see cref="Reply"/> is produced once per refresh and reused.
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
||||
/// materialized object list and precomputed dashboard projection.
|
||||
/// </summary>
|
||||
public sealed record GalaxyHierarchyCacheEntry(
|
||||
GalaxyCacheStatus Status,
|
||||
@@ -14,9 +15,9 @@ public sealed record GalaxyHierarchyCacheEntry(
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
DateTimeOffset? LastDeployTime,
|
||||
string? LastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> Attributes,
|
||||
DiscoverHierarchyReply? Reply,
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
GalaxyHierarchyIndex Index,
|
||||
DashboardGalaxySummary DashboardSummary,
|
||||
int ObjectCount,
|
||||
int AreaCount,
|
||||
int AttributeCount,
|
||||
@@ -31,9 +32,9 @@ public sealed record GalaxyHierarchyCacheEntry(
|
||||
LastSuccessAt: null,
|
||||
LastDeployTime: null,
|
||||
LastError: null,
|
||||
Hierarchy: Array.Empty<GalaxyHierarchyRow>(),
|
||||
Attributes: Array.Empty<GalaxyAttributeRow>(),
|
||||
Reply: null,
|
||||
Objects: Array.Empty<GalaxyObject>(),
|
||||
Index: GalaxyHierarchyIndex.Empty,
|
||||
DashboardSummary: DashboardGalaxySummary.Unknown,
|
||||
ObjectCount: 0,
|
||||
AreaCount: 0,
|
||||
AttributeCount: 0,
|
||||
|
||||
@@ -3,6 +3,8 @@ using Grpc.Core;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using GalaxyDb = MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
|
||||
|
||||
namespace MxGateway.Server.Grpc;
|
||||
@@ -18,9 +20,12 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
GalaxyDb.GalaxyRepository repository,
|
||||
GalaxyDb.IGalaxyHierarchyCache cache,
|
||||
GalaxyDb.IGalaxyDeployNotifier notifier,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||
{
|
||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||
private const int DefaultDiscoverPageSize = 1000;
|
||||
private const int MaxDiscoverPageSize = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TestConnectionReply> TestConnection(
|
||||
@@ -62,16 +67,44 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData || entry.Reply is null)
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
// Same materialized reply is shared across all clients — gRPC serialization is
|
||||
// read-only and the entry is replaced atomically on the next refresh.
|
||||
return entry.Reply;
|
||||
int pageSize = ResolvePageSize(request.PageSize);
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
request,
|
||||
browseSubtrees,
|
||||
pageToken.Offset,
|
||||
pageSize);
|
||||
int offset = pageToken.Offset;
|
||||
if (offset > query.TotalObjectCount)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
||||
}
|
||||
|
||||
DiscoverHierarchyReply reply = new()
|
||||
{
|
||||
TotalObjectCount = query.TotalObjectCount,
|
||||
};
|
||||
reply.Objects.Add(query.Objects);
|
||||
|
||||
int nextOffset = offset + query.Objects.Count;
|
||||
if (nextOffset < query.TotalObjectCount)
|
||||
{
|
||||
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -96,7 +129,7 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
}
|
||||
lastSeen = null;
|
||||
|
||||
await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false);
|
||||
await responseStream.WriteAsync(MapDeployEvent(info, ResolveBrowseSubtrees()), context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +157,28 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
}
|
||||
}
|
||||
|
||||
private static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info)
|
||||
private DeployEvent MapDeployEvent(
|
||||
GalaxyDb.GalaxyDeployEventInfo info,
|
||||
IReadOnlyList<string> browseSubtrees)
|
||||
{
|
||||
int objectCount = info.ObjectCount;
|
||||
int attributeCount = info.AttributeCount;
|
||||
if (browseSubtrees.Count > 0 && cache.Current.HasData)
|
||||
{
|
||||
GalaxyDb.GalaxyHierarchyQueryResult scoped = GalaxyDb.GalaxyHierarchyProjector.Project(
|
||||
cache.Current,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtrees);
|
||||
objectCount = scoped.TotalObjectCount;
|
||||
attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count);
|
||||
}
|
||||
|
||||
DeployEvent ev = new()
|
||||
{
|
||||
Sequence = (ulong)info.Sequence,
|
||||
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||
ObjectCount = info.ObjectCount,
|
||||
AttributeCount = info.AttributeCount,
|
||||
ObjectCount = objectCount,
|
||||
AttributeCount = attributeCount,
|
||||
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
||||
};
|
||||
if (info.TimeOfLastDeploy.HasValue)
|
||||
@@ -148,6 +195,80 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
_ => "Galaxy cache has no data available.",
|
||||
};
|
||||
|
||||
private static int ResolvePageSize(int requestedPageSize)
|
||||
{
|
||||
if (requestedPageSize < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_size must be greater than zero when provided."));
|
||||
}
|
||||
|
||||
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveBrowseSubtrees()
|
||||
{
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
return constraints.BrowseSubtrees;
|
||||
}
|
||||
|
||||
private static string FormatPageToken(long sequence, string filterSignature, int offset)
|
||||
{
|
||||
return string.Concat(
|
||||
sequence.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
":",
|
||||
filterSignature,
|
||||
":",
|
||||
offset.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pageToken))
|
||||
{
|
||||
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
|
||||
}
|
||||
|
||||
string[] parts = pageToken.Split(':', count: 3);
|
||||
if (parts.Length != 3
|
||||
|| !long.TryParse(
|
||||
parts[0],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out long sequence)
|
||||
|| !int.TryParse(
|
||||
parts[2],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int offset)
|
||||
|| offset < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is invalid."));
|
||||
}
|
||||
|
||||
if (sequence != currentSequence)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is stale."));
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token does not match the current filters."));
|
||||
}
|
||||
|
||||
return new PageToken(sequence, parts[1], offset);
|
||||
}
|
||||
|
||||
private sealed record PageToken(long Sequence, string FilterSignature, int Offset);
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style",
|
||||
"IDE0051:Remove unused private members",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -13,6 +15,7 @@ namespace MxGateway.Server.Grpc;
|
||||
public sealed class MxAccessGatewayService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
IConstraintEnforcer constraintEnforcer,
|
||||
MxAccessGrpcRequestValidator requestValidator,
|
||||
MxAccessGrpcMapper mapper,
|
||||
IEventStreamService eventStreamService,
|
||||
@@ -91,12 +94,35 @@ public sealed class MxAccessGatewayService(
|
||||
try
|
||||
{
|
||||
requestValidator.ValidateInvoke(request);
|
||||
WorkerCommand workerCommand = mapper.MapCommand(request);
|
||||
GatewaySession session = ResolveSession(request.SessionId);
|
||||
MxCommand command = request.Command;
|
||||
BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync(
|
||||
session,
|
||||
command,
|
||||
context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command;
|
||||
if (bulkConstraintPlan is { HasAllowedItems: false })
|
||||
{
|
||||
return CreateDeniedBulkReply(request, bulkConstraintPlan);
|
||||
}
|
||||
|
||||
MxCommandRequest invokeRequest = request.Clone();
|
||||
invokeRequest.Command = commandToInvoke;
|
||||
WorkerCommand workerCommand = mapper.MapCommand(invokeRequest);
|
||||
WorkerCommandReply workerReply = await sessionManager
|
||||
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return mapper.MapCommandReply(workerReply);
|
||||
MxCommandReply publicReply = mapper.MapCommandReply(workerReply);
|
||||
if (bulkConstraintPlan is not null)
|
||||
{
|
||||
publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan);
|
||||
}
|
||||
|
||||
session.TrackCommandReply(commandToInvoke, publicReply);
|
||||
return publicReply;
|
||||
}
|
||||
catch (Exception exception) when (exception is not RpcException)
|
||||
{
|
||||
@@ -134,6 +160,323 @@ public sealed class MxAccessGatewayService(
|
||||
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
|
||||
}
|
||||
|
||||
private GatewaySession ResolveSession(string sessionId)
|
||||
{
|
||||
if (!sessionManager.TryGetSession(sessionId, out GatewaySession session))
|
||||
{
|
||||
throw new SessionManagerException(
|
||||
SessionManagerErrorCode.SessionNotFound,
|
||||
$"Session {sessionId} was not found.");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> ApplyConstraintsAsync(
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiKeyIdentity? identity = identityAccessor.Current;
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItem:
|
||||
await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.AddItem2:
|
||||
await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.AddItemBulk:
|
||||
return await FilterTagBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.AddItemBulk.ServerHandle,
|
||||
command.AddItemBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
return await FilterTagBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.SubscribeBulk.ServerHandle,
|
||||
command.SubscribeBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
return await FilterHandleBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.AdviseItemBulk.ServerHandle,
|
||||
command.AdviseItemBulk.ItemHandles,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.Write.ServerHandle,
|
||||
command.Write.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.Write2:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.Write2.ServerHandle,
|
||||
command.Write2.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.WriteSecured:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.WriteSecured.ServerHandle,
|
||||
command.WriteSecured.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.WriteSecured2:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.WriteSecured2.ServerHandle,
|
||||
command.WriteSecured2.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnforceReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommandKind commandKind,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
|
||||
}
|
||||
|
||||
private async Task EnforceWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommandKind commandKind,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterTagBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<string> allowed = [];
|
||||
for (int index = 0; index < tagAddresses.Count; index++)
|
||||
{
|
||||
string tagAddress = tagAddresses[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(tagAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new SubscribeResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
if (filtered.Kind == MxCommandKind.AddItemBulk)
|
||||
{
|
||||
filtered.AddItemBulk.TagAddresses.Clear();
|
||||
filtered.AddItemBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
else
|
||||
{
|
||||
filtered.SubscribeBulk.TagAddresses.Clear();
|
||||
filtered.SubscribeBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
|
||||
return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterHandleBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<int> itemHandles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<int> allowed = [];
|
||||
for (int index = 0; index < itemHandles.Count; index++)
|
||||
{
|
||||
int itemHandle = itemHandles[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(itemHandle);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new SubscribeResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
filtered.AdviseItemBulk.ItemHandles.Clear();
|
||||
filtered.AdviseItemBulk.ItemHandles.Add(allowed);
|
||||
|
||||
return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateDeniedBulkReply(
|
||||
MxCommandRequest request,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply MergeDeniedBulkResults(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply();
|
||||
SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply BuildMergedBulkReply(
|
||||
BulkSubscribeReply allowed,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < plan.OriginalCount; index++)
|
||||
{
|
||||
if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind)
|
||||
{
|
||||
return commandKind switch
|
||||
{
|
||||
MxCommandKind.AddItemBulk => reply.AddItemBulk,
|
||||
MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => reply.SubscribeBulk,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetBulkPayload(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkSubscribeReply payload)
|
||||
{
|
||||
switch (commandKind)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems);
|
||||
|
||||
private RpcException MapException(Exception exception)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ public sealed class ApiKeyAdminCliRunner(
|
||||
SecretHash: hasher.HashSecret(secret),
|
||||
DisplayName: Required(command.DisplayName),
|
||||
Scopes: command.Scopes,
|
||||
Constraints: command.Constraints,
|
||||
CreatedUtc: DateTimeOffset.UtcNow),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -172,6 +173,7 @@ public sealed class ApiKeyAdminCliRunner(
|
||||
KeyPrefix: key.KeyPrefix,
|
||||
DisplayName: key.DisplayName,
|
||||
Scopes: key.Scopes,
|
||||
Constraints: key.Constraints,
|
||||
CreatedUtc: key.CreatedUtc,
|
||||
LastUsedUtc: key.LastUsedUtc,
|
||||
RevokedUtc: key.RevokedUtc);
|
||||
|
||||
@@ -7,4 +7,5 @@ public sealed record ApiKeyAdminCommand(
|
||||
string? Pepper,
|
||||
string? KeyId,
|
||||
string? DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints);
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class ApiKeyAdminCommandLineParser
|
||||
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
|
||||
}
|
||||
|
||||
Dictionary<string, string?> options = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, List<string?>> options = new(StringComparer.OrdinalIgnoreCase);
|
||||
bool json = false;
|
||||
|
||||
for (int index = 2; index < args.Count; index++)
|
||||
@@ -52,18 +52,42 @@ public static class ApiKeyAdminCommandLineParser
|
||||
{
|
||||
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
|
||||
if (IsBooleanConstraintFlag(name))
|
||||
{
|
||||
value = "true";
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
value = args[++index];
|
||||
}
|
||||
|
||||
value = args[++index];
|
||||
}
|
||||
|
||||
options[name] = value;
|
||||
if (!options.TryGetValue(name, out List<string?>? values))
|
||||
{
|
||||
values = [];
|
||||
options[name] = values;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
string? keyId = GetOption(options, "key-id");
|
||||
string? displayName = GetOption(options, "display-name");
|
||||
IReadOnlySet<string> scopes = ParseScopes(GetOption(options, "scopes"));
|
||||
ApiKeyConstraints constraints;
|
||||
try
|
||||
{
|
||||
constraints = ParseConstraints(options);
|
||||
}
|
||||
catch (FormatException exception)
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail(exception.Message);
|
||||
}
|
||||
|
||||
string? validationError = Validate(kind, keyId, displayName);
|
||||
if (validationError is not null)
|
||||
@@ -78,7 +102,8 @@ public static class ApiKeyAdminCommandLineParser
|
||||
Pepper: GetOption(options, "pepper"),
|
||||
KeyId: keyId,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes));
|
||||
Scopes: scopes,
|
||||
Constraints: constraints));
|
||||
}
|
||||
|
||||
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
|
||||
@@ -147,9 +172,56 @@ public static class ApiKeyAdminCommandLineParser
|
||||
|| character is '.' or '-');
|
||||
}
|
||||
|
||||
private static string? GetOption(Dictionary<string, string?> options, string name)
|
||||
private static string? GetOption(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out string? value) ? value : null;
|
||||
return options.TryGetValue(name, out List<string?>? values) && values.Count > 0 ? values[^1] : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetOptions(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out List<string?>? values)
|
||||
? values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static bool HasFlag(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.ContainsKey(name);
|
||||
}
|
||||
|
||||
private static bool IsBooleanConstraintFlag(string name)
|
||||
{
|
||||
return string.Equals(name, "read-alarm-only", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "read-historized-only", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ApiKeyConstraints ParseConstraints(Dictionary<string, List<string?>> options)
|
||||
{
|
||||
return new ApiKeyConstraints(
|
||||
ReadSubtrees: GetOptions(options, "read-subtree"),
|
||||
WriteSubtrees: GetOptions(options, "write-subtree"),
|
||||
ReadTagGlobs: GetOptions(options, "read-tag-glob"),
|
||||
WriteTagGlobs: GetOptions(options, "write-tag-glob"),
|
||||
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
|
||||
BrowseSubtrees: GetOptions(options, "browse-subtree"),
|
||||
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
|
||||
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
|
||||
}
|
||||
|
||||
private static int? ParseNullableInt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
value,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int parsed)
|
||||
? parsed
|
||||
: throw new FormatException("--max-write-classification must be an integer.");
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> ParseScopes(string? scopes)
|
||||
|
||||
@@ -5,6 +5,7 @@ public sealed record ApiKeyAdminListedKey(
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -6,4 +6,5 @@ public sealed record ApiKeyCreateRequest(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc);
|
||||
|
||||
@@ -4,4 +4,8 @@ public sealed record ApiKeyIdentity(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints? Constraints = null)
|
||||
{
|
||||
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public sealed record ApiKeyRecord(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -16,9 +16,10 @@ public static class ApiKeyRecordReader
|
||||
SecretHash: (byte[])reader["secret_hash"],
|
||||
DisplayName: reader.GetString(3),
|
||||
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
|
||||
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class ApiKeyVerifier(
|
||||
KeyId: storedKey.KeyId,
|
||||
KeyPrefix: storedKey.KeyPrefix,
|
||||
DisplayName: storedKey.DisplayName,
|
||||
Scopes: storedKey.Scopes));
|
||||
Scopes: storedKey.Scopes,
|
||||
Constraints: storedKey.Constraints));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
secret_hash,
|
||||
display_name,
|
||||
scopes,
|
||||
constraints,
|
||||
created_utc,
|
||||
last_used_utc,
|
||||
revoked_utc)
|
||||
@@ -30,6 +31,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
$secret_hash,
|
||||
$display_name,
|
||||
$scopes,
|
||||
$constraints,
|
||||
$created_utc,
|
||||
NULL,
|
||||
NULL);
|
||||
@@ -47,7 +49,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
ORDER BY key_id;
|
||||
""";
|
||||
@@ -118,6 +120,9 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
|
||||
command.Parameters.AddWithValue("$display_name", request.DisplayName);
|
||||
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
|
||||
command.Parameters.AddWithValue(
|
||||
"$constraints",
|
||||
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,12 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
? """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
"""
|
||||
: """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -85,6 +87,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
secret_hash BLOB NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
constraints TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
@@ -107,6 +110,34 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
""",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionTwoAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
ALTER TABLE api_keys
|
||||
ADD COLUMN constraints TEXT NULL;
|
||||
""",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
@@ -122,6 +153,31 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<bool> ColumnExistsAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string tableName,
|
||||
string columnName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = $"PRAGMA table_info({tableName});";
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
|
||||
+12
@@ -1,4 +1,6 @@
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authorization;
|
||||
|
||||
@@ -15,7 +17,17 @@ public static class GrpcAuthorizationServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
||||
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||
services
|
||||
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
||||
.Configure<IConfiguration>((grpcOptions, configuration) =>
|
||||
{
|
||||
ProtocolOptions protocolOptions = new();
|
||||
configuration.GetSection("MxGateway:Protocol").Bind(protocolOptions);
|
||||
grpcOptions.MaxReceiveMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||
grpcOptions.MaxSendMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||
});
|
||||
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
||||
|
||||
return services;
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class GatewaySession
|
||||
private DateTimeOffset? _leaseExpiresAt;
|
||||
private bool _closeStarted;
|
||||
private int _activeEventSubscriberCount;
|
||||
private readonly Dictionary<(int ServerHandle, int ItemHandle), SessionItemRegistration> _items = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a gateway session with session metadata and timeout configuration.
|
||||
@@ -41,6 +42,35 @@ public sealed class GatewaySession
|
||||
TimeSpan startupTimeout,
|
||||
TimeSpan shutdownTimeout,
|
||||
DateTimeOffset openedAt)
|
||||
: this(
|
||||
sessionId,
|
||||
backendName,
|
||||
pipeName,
|
||||
nonce,
|
||||
clientIdentity,
|
||||
clientSessionName,
|
||||
clientCorrelationId,
|
||||
commandTimeout,
|
||||
startupTimeout,
|
||||
shutdownTimeout,
|
||||
TimeSpan.FromMinutes(30),
|
||||
openedAt)
|
||||
{
|
||||
}
|
||||
|
||||
public GatewaySession(
|
||||
string sessionId,
|
||||
string backendName,
|
||||
string pipeName,
|
||||
string nonce,
|
||||
string? clientIdentity,
|
||||
string? clientSessionName,
|
||||
string? clientCorrelationId,
|
||||
TimeSpan commandTimeout,
|
||||
TimeSpan startupTimeout,
|
||||
TimeSpan shutdownTimeout,
|
||||
TimeSpan leaseDuration,
|
||||
DateTimeOffset openedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
@@ -72,8 +102,10 @@ public sealed class GatewaySession
|
||||
CommandTimeout = commandTimeout;
|
||||
StartupTimeout = startupTimeout;
|
||||
ShutdownTimeout = shutdownTimeout;
|
||||
LeaseDuration = leaseDuration;
|
||||
OpenedAt = openedAt;
|
||||
_lastClientActivityAt = openedAt;
|
||||
_leaseExpiresAt = openedAt + leaseDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +158,8 @@ public sealed class GatewaySession
|
||||
/// </summary>
|
||||
public TimeSpan ShutdownTimeout { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the session opened.
|
||||
/// </summary>
|
||||
@@ -282,6 +316,7 @@ public sealed class GatewaySession
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_lastClientActivityAt = activityAt;
|
||||
_leaseExpiresAt = activityAt + LeaseDuration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +340,9 @@ public sealed class GatewaySession
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
|
||||
return _activeEventSubscriberCount == 0
|
||||
&& _leaseExpiresAt is not null
|
||||
&& _leaseExpiresAt <= now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +388,58 @@ public sealed class GatewaySession
|
||||
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public bool TryGetItemRegistration(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
out SessionItemRegistration registration)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _items.TryGetValue((serverHandle, itemHandle), out registration!);
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackCommandReply(
|
||||
MxCommand command,
|
||||
MxCommandReply reply)
|
||||
{
|
||||
if (reply.ProtocolStatus?.Code is not ProtocolStatusCode.Ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItem when reply.AddItem is not null:
|
||||
TrackItem(command.AddItem.ServerHandle, reply.AddItem.ItemHandle, command.AddItem.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddItem2 when reply.AddItem2 is not null:
|
||||
TrackItem(command.AddItem2.ServerHandle, reply.AddItem2.ItemHandle, command.AddItem2.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddBufferedItem when reply.AddBufferedItem is not null:
|
||||
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, command.AddBufferedItem.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddItemBulk when reply.AddItemBulk is not null:
|
||||
TrackBulkItems(reply.AddItemBulk);
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk when reply.SubscribeBulk is not null:
|
||||
TrackBulkItems(reply.SubscribeBulk);
|
||||
break;
|
||||
case MxCommandKind.RemoveItem:
|
||||
_items.Remove((command.RemoveItem.ServerHandle, command.RemoveItem.ItemHandle));
|
||||
break;
|
||||
case MxCommandKind.RemoveItemBulk:
|
||||
RemoveItems(command.RemoveItemBulk.ServerHandle, command.RemoveItemBulk.ItemHandles);
|
||||
break;
|
||||
case MxCommandKind.UnsubscribeBulk:
|
||||
RemoveItems(command.UnsubscribeBulk.ServerHandle, command.UnsubscribeBulk.ItemHandles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk add-item command for the specified server and tag addresses.
|
||||
/// </summary>
|
||||
@@ -641,6 +730,40 @@ public sealed class GatewaySession
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackItem(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
string tagAddress)
|
||||
{
|
||||
if (itemHandle == 0 || string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_items[(serverHandle, itemHandle)] = new SessionItemRegistration(serverHandle, itemHandle, tagAddress);
|
||||
}
|
||||
|
||||
private void TrackBulkItems(BulkSubscribeReply reply)
|
||||
{
|
||||
foreach (SubscribeResult result in reply.Results)
|
||||
{
|
||||
if (result.WasSuccessful)
|
||||
{
|
||||
TrackItem(result.ServerHandle, result.ItemHandle, result.TagAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveItems(
|
||||
int serverHandle,
|
||||
IEnumerable<int> itemHandles)
|
||||
{
|
||||
foreach (int itemHandle in itemHandles)
|
||||
{
|
||||
_items.Remove((serverHandle, itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachEventSubscriber()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
|
||||
@@ -339,6 +339,7 @@ public sealed class SessionManager : ISessionManager
|
||||
TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout);
|
||||
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
|
||||
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
|
||||
TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds);
|
||||
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
|
||||
string nonce = CreateNonce();
|
||||
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||
@@ -355,6 +356,7 @@ public sealed class SessionManager : ISessionManager
|
||||
commandTimeout,
|
||||
startupTimeout,
|
||||
shutdownTimeout,
|
||||
leaseDuration,
|
||||
openedAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class SessionServiceCollectionExtensions
|
||||
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
||||
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
||||
services.AddSingleton<ISessionManager, SessionManager>();
|
||||
services.AddHostedService<SessionLeaseMonitorHostedService>();
|
||||
services.AddHostedService<SessionShutdownHostedService>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -254,11 +254,17 @@ public sealed class WorkerClient : IWorkerClient
|
||||
}
|
||||
|
||||
WorkerClientState state = State;
|
||||
if (state is WorkerClientState.Closed or WorkerClientState.Faulted)
|
||||
if (state == WorkerClientState.Closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == WorkerClientState.Faulted)
|
||||
{
|
||||
KillOwnedProcess("ShutdownFaulted");
|
||||
return;
|
||||
}
|
||||
|
||||
MarkClosing();
|
||||
await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false);
|
||||
_outboundEnvelopes.Writer.TryComplete();
|
||||
@@ -288,8 +294,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
public void Kill(string reason)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_connection.ProcessHandle?.Process.Kill(entireProcessTree: true);
|
||||
_metrics?.WorkerKilled(reason);
|
||||
KillOwnedProcess(reason);
|
||||
SetFaulted(
|
||||
WorkerClientErrorCode.WorkerFaulted,
|
||||
$"Worker was killed by the gateway: {reason}.",
|
||||
@@ -305,6 +310,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
KillOwnedProcess("Dispose");
|
||||
_stopCts.Cancel();
|
||||
_outboundEnvelopes.Writer.TryComplete();
|
||||
_events.Writer.TryComplete();
|
||||
@@ -666,12 +672,39 @@ public sealed class WorkerClient : IWorkerClient
|
||||
_stopCts.Cancel();
|
||||
_outboundEnvelopes.Writer.TryComplete(fault);
|
||||
_events.Writer.TryComplete(fault);
|
||||
KillOwnedProcess(errorCode.ToString());
|
||||
CompletePendingCommands(fault);
|
||||
RecordWorkerStoppedOnce(errorCode.ToString());
|
||||
_metrics?.Fault(errorCode.ToString());
|
||||
_logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message);
|
||||
}
|
||||
|
||||
private void KillOwnedProcess(string reason)
|
||||
{
|
||||
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
|
||||
if (processHandle is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!processHandle.Process.HasExited)
|
||||
{
|
||||
processHandle.Process.Kill(entireProcessTree: true);
|
||||
_metrics?.WorkerKilled(reason);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Failed to kill worker process {ProcessId} for session {SessionId}.",
|
||||
processHandle.ProcessId,
|
||||
SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records worker stopped metric only once.</summary>
|
||||
/// <param name="reason">Reason for stopping.</param>
|
||||
private void RecordWorkerStoppedOnce(string reason)
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||
"RunMigrationsOnStartup": true
|
||||
},
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"UserNameAttribute": "cn",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"RequiredGroup": "GwAdmin"
|
||||
},
|
||||
"Worker": {
|
||||
"ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe",
|
||||
"RequiredArchitecture": "X86",
|
||||
@@ -25,6 +39,9 @@
|
||||
"Sessions": {
|
||||
"DefaultCommandTimeoutSeconds": 30,
|
||||
"MaxSessions": 64,
|
||||
"MaxPendingCommandsPerSession": 128,
|
||||
"DefaultLeaseSeconds": 1800,
|
||||
"LeaseSweepIntervalSeconds": 30,
|
||||
"AllowMultipleEventSubscribers": false
|
||||
},
|
||||
"Events": {
|
||||
@@ -42,7 +59,8 @@
|
||||
"ShowTagValues": false
|
||||
},
|
||||
"Protocol": {
|
||||
"WorkerProtocolVersion": 1
|
||||
"WorkerProtocolVersion": 1,
|
||||
"MaxGrpcMessageBytes": 16777216
|
||||
},
|
||||
"Galaxy": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
|
||||
@@ -122,6 +122,33 @@
|
||||
border-radius: .375rem;
|
||||
}
|
||||
|
||||
.api-key-management-grid {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
}
|
||||
|
||||
.scope-grid {
|
||||
display: grid;
|
||||
gap: .35rem .75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.one-time-secret {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.api-key-create-modal {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.api-key-create-modal .modal-body {
|
||||
max-height: min(70vh, 44rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.dashboard-content {
|
||||
padding: .75rem;
|
||||
|
||||
@@ -31,6 +31,8 @@ public sealed class GatewayOptionsTests
|
||||
|
||||
Assert.Equal(30, options.Sessions.DefaultCommandTimeoutSeconds);
|
||||
Assert.Equal(64, options.Sessions.MaxSessions);
|
||||
Assert.Equal(1800, options.Sessions.DefaultLeaseSeconds);
|
||||
Assert.Equal(30, options.Sessions.LeaseSweepIntervalSeconds);
|
||||
Assert.False(options.Sessions.AllowMultipleEventSubscribers);
|
||||
|
||||
Assert.Equal(10_000, options.Events.QueueCapacity);
|
||||
@@ -46,6 +48,7 @@ public sealed class GatewayOptionsTests
|
||||
Assert.False(options.Dashboard.ShowTagValues);
|
||||
|
||||
Assert.Equal(1u, options.Protocol.WorkerProtocolVersion);
|
||||
Assert.Equal(16 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that options binding applies configuration overrides.</summary>
|
||||
@@ -58,15 +61,19 @@ public sealed class GatewayOptionsTests
|
||||
["MxGateway:Authentication:Mode"] = "Disabled",
|
||||
["MxGateway:Worker:ExecutablePath"] = @"C:\Gateway\MxGateway.Worker.exe",
|
||||
["MxGateway:Sessions:MaxSessions"] = "12",
|
||||
["MxGateway:Sessions:DefaultLeaseSeconds"] = "900",
|
||||
["MxGateway:Events:QueueCapacity"] = "256",
|
||||
["MxGateway:Dashboard:Enabled"] = "false"
|
||||
["MxGateway:Dashboard:Enabled"] = "false",
|
||||
["MxGateway:Protocol:MaxGrpcMessageBytes"] = "8388608"
|
||||
});
|
||||
|
||||
Assert.Equal(AuthenticationMode.Disabled, options.Authentication.Mode);
|
||||
Assert.Equal(@"C:\Gateway\MxGateway.Worker.exe", options.Worker.ExecutablePath);
|
||||
Assert.Equal(12, options.Sessions.MaxSessions);
|
||||
Assert.Equal(900, options.Sessions.DefaultLeaseSeconds);
|
||||
Assert.Equal(256, options.Events.QueueCapacity);
|
||||
Assert.False(options.Dashboard.Enabled);
|
||||
Assert.Equal(8 * 1024 * 1024, options.Protocol.MaxGrpcMessageBytes);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that invalid configuration values fail with expected error messages.</summary>
|
||||
@@ -77,7 +84,10 @@ public sealed class GatewayOptionsTests
|
||||
[InlineData("MxGateway:Worker:ExecutablePath", "worker.dll", "MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
||||
[InlineData("MxGateway:Worker:StartupProbeRetryAttempts", "0", "MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.")]
|
||||
[InlineData("MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds", "0", "MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.")]
|
||||
[InlineData("MxGateway:Sessions:DefaultLeaseSeconds", "0", "MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.")]
|
||||
[InlineData("MxGateway:Sessions:LeaseSweepIntervalSeconds", "0", "MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.")]
|
||||
[InlineData("MxGateway:Events:QueueCapacity", "0", "MxGateway:Events:QueueCapacity must be greater than zero.")]
|
||||
[InlineData("MxGateway:Protocol:MaxGrpcMessageBytes", "0", "MxGateway:Protocol:MaxGrpcMessageBytes must be between")]
|
||||
[InlineData("MxGateway:Authentication:PepperSecretName", "", "MxGateway:Authentication:PepperSecretName is required")]
|
||||
[InlineData("MxGateway:Dashboard:PathBase", "dashboard", "MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
public void Validation_InvalidConfiguration_FailsClearly(string key, string value, string expectedFailure)
|
||||
|
||||
@@ -347,7 +347,7 @@ public sealed class ClientBehaviorFixtureTests
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||
{
|
||||
|
||||
@@ -91,7 +91,7 @@ public sealed class ClientProtoInputTests
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||
{
|
||||
|
||||
@@ -264,7 +264,7 @@ public sealed class CrossLanguageSmokeMatrixTests
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||
{
|
||||
|
||||
@@ -13,9 +13,9 @@ public sealed class GatewayContractInfoTests
|
||||
|
||||
/// <summary>Verifies that the gateway protocol version starts at version one.</summary>
|
||||
[Fact]
|
||||
public void GatewayProtocolVersion_StartsAtVersionOne()
|
||||
public void GatewayProtocolVersion_IsVersionTwo()
|
||||
{
|
||||
Assert.Equal(1u, GatewayContractInfo.GatewayProtocolVersion);
|
||||
Assert.Equal(2u, GatewayContractInfo.GatewayProtocolVersion);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the worker protocol version starts at version one.</summary>
|
||||
|
||||
@@ -282,7 +282,7 @@ public sealed class ParityFixtureMatrixTests
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current.FullName, "AGENTS.md"))
|
||||
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
||||
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace MxGateway.Tests.Galaxy;
|
||||
|
||||
@@ -18,7 +19,7 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
|
||||
Assert.False(entry.HasData);
|
||||
Assert.Equal(0, entry.ObjectCount);
|
||||
Assert.Null(entry.Reply);
|
||||
Assert.Empty(entry.Objects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,6 +65,53 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
|
||||
{
|
||||
GalaxyObject root = new()
|
||||
{
|
||||
GobjectId = 1,
|
||||
TagName = "Area1",
|
||||
ContainedName = "Area1",
|
||||
};
|
||||
GalaxyObject duplicate = new()
|
||||
{
|
||||
GobjectId = 1,
|
||||
TagName = "DuplicateArea",
|
||||
ContainedName = "DuplicateArea",
|
||||
};
|
||||
GalaxyObject child = new()
|
||||
{
|
||||
GobjectId = 2,
|
||||
ParentGobjectId = 1,
|
||||
TagName = "Pump_001",
|
||||
ContainedName = "Pump",
|
||||
Attributes =
|
||||
{
|
||||
new GalaxyAttribute
|
||||
{
|
||||
FullTagReference = "Pump_001.PV",
|
||||
IsHistorized = true,
|
||||
},
|
||||
},
|
||||
};
|
||||
GalaxyObject orphan = new()
|
||||
{
|
||||
GobjectId = 3,
|
||||
ParentGobjectId = 99,
|
||||
TagName = "Orphan_001",
|
||||
ContainedName = "Orphan",
|
||||
};
|
||||
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);
|
||||
|
||||
Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
|
||||
Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
|
||||
Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
|
||||
Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
|
||||
Assert.Same(root, index.ObjectViewsById[1].Object);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
@@ -71,7 +119,7 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
|
||||
CommandTimeoutSeconds = 1,
|
||||
};
|
||||
GalaxyRepository repository = new(options);
|
||||
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
|
||||
return new GalaxyHierarchyCache(repository, notifier, clock);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,135 +1,74 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for dashboard authentication using API keys.
|
||||
/// </summary>
|
||||
public sealed class DashboardAuthenticatorTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies an admin-scoped key produces a valid cookie principal.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
|
||||
public void EscapeLdapFilter_EscapesSpecialCharacters()
|
||||
{
|
||||
FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(verifier);
|
||||
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
|
||||
|
||||
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);
|
||||
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a non-admin key fails authentication without exposing the API key.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", true)]
|
||||
[InlineData("gwadmin", true)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", true)]
|
||||
[InlineData("OtherGroup", false)]
|
||||
public void IsMemberOfRequiredGroup_MatchesShortNameAndDistinguishedName(
|
||||
string requiredGroup,
|
||||
bool expected)
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
|
||||
SuccessWithScopes(GatewayScopes.EventsRead)));
|
||||
string[] groups =
|
||||
[
|
||||
"ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local",
|
||||
"ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local"
|
||||
];
|
||||
|
||||
bool result = DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
|
||||
Assert.Equal(expected, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
|
||||
{
|
||||
string result = DashboardAuthenticator.ExtractFirstRdnValue(
|
||||
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
|
||||
|
||||
Assert.Equal("Gateway Admins", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
|
||||
{
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
|
||||
{
|
||||
Ldap = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
},
|
||||
});
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"mxgw_operator01_super-secret",
|
||||
"admin",
|
||||
"admin123",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Null(result.Principal);
|
||||
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when admin scope is not required, any authenticated key is accepted.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an invalid key returns a generic failure message.
|
||||
/// </summary>
|
||||
[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)
|
||||
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
|
||||
{
|
||||
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)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test implementation that records the authorization header for verification.
|
||||
/// </summary>
|
||||
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// The authorization header that was last verified.
|
||||
/// </summary>
|
||||
public string? LastAuthorizationHeader { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ApiKeyVerificationResult> VerifyAsync(
|
||||
string? authorizationHeader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LastAuthorizationHeader = authorizationHeader;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
Options.Create(options),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
@@ -200,17 +202,27 @@ public sealed class DashboardSnapshotServiceTests
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
|
||||
Hierarchy =
|
||||
[
|
||||
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Pump_001", BrowseName = "Pump_001", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
|
||||
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump_002", BrowseName = "Pump_002", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
|
||||
new GalaxyHierarchyRow { GobjectId = 3, TagName = "Area_A", BrowseName = "Area_A", CategoryId = 13, IsArea = true, TemplateChain = ["$Area"] },
|
||||
],
|
||||
Attributes =
|
||||
[
|
||||
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Speed", IsHistorized = true },
|
||||
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Status", IsAlarm = true },
|
||||
],
|
||||
DashboardSummary = new DashboardGalaxySummary(
|
||||
DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
|
||||
LastError: null,
|
||||
ObjectCount: 3,
|
||||
AreaCount: 1,
|
||||
AttributeCount: 2,
|
||||
HistorizedAttributeCount: 1,
|
||||
AlarmAttributeCount: 1,
|
||||
TopTemplates:
|
||||
[
|
||||
new DashboardGalaxyTemplateUsage("$Pump", 2),
|
||||
new DashboardGalaxyTemplateUsage("$Area", 1),
|
||||
],
|
||||
ObjectCategories:
|
||||
[
|
||||
new DashboardGalaxyCategoryCount(10, "UserDefined", 2),
|
||||
new DashboardGalaxyCategoryCount(13, "Area", 1),
|
||||
]),
|
||||
ObjectCount = 3,
|
||||
AreaCount = 1,
|
||||
AttributeCount = 2,
|
||||
@@ -238,6 +250,101 @@ public sealed class DashboardSnapshotServiceTests
|
||||
/// <summary>
|
||||
/// Verifies snapshot watcher cancels cleanly when subscriber cancels.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
CountingApiKeyAdminStore apiKeyAdminStore = new();
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
|
||||
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||
|
||||
Assert.Empty(snapshot.ApiKeys);
|
||||
Assert.Equal(0, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
||||
new ApiKeyRecord(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
SecretHash: [1, 2, 3],
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty with
|
||||
{
|
||||
BrowseSubtrees = ["Area1/*"],
|
||||
},
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot snapshot = enumerator.Current;
|
||||
|
||||
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
|
||||
Assert.Equal("operator01", key.KeyId);
|
||||
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
|
||||
Assert.Equal(1, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
|
||||
{
|
||||
using GatewayMetrics metrics = new();
|
||||
SequencedApiKeyAdminStore apiKeyAdminStore = new(
|
||||
new ApiKeyRecord(
|
||||
KeyId: "operator01",
|
||||
KeyPrefix: "mxgw_operator01",
|
||||
SecretHash: [1, 2, 3],
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty,
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
new SessionRegistry(),
|
||||
metrics,
|
||||
new GatewayOptions
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
SnapshotIntervalMilliseconds = 1,
|
||||
},
|
||||
},
|
||||
apiKeyAdminStore: apiKeyAdminStore);
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||
.WatchSnapshotsAsync(cancellation.Token)
|
||||
.GetAsyncEnumerator(cancellation.Token);
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot first = enumerator.Current;
|
||||
apiKeyAdminStore.FailNext = true;
|
||||
|
||||
Assert.True(await enumerator.MoveNextAsync());
|
||||
DashboardSnapshot second = enumerator.Current;
|
||||
|
||||
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
|
||||
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
|
||||
Assert.Equal(2, apiKeyAdminStore.ListCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||
{
|
||||
@@ -268,7 +375,8 @@ public sealed class DashboardSnapshotServiceTests
|
||||
SessionRegistry registry,
|
||||
GatewayMetrics metrics,
|
||||
GatewayOptions? options = null,
|
||||
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
|
||||
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
|
||||
IApiKeyAdminStore? apiKeyAdminStore = null)
|
||||
{
|
||||
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||
{
|
||||
@@ -284,6 +392,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
metrics,
|
||||
configurationProvider,
|
||||
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
|
||||
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
|
||||
Options.Create(resolvedOptions));
|
||||
}
|
||||
|
||||
@@ -309,6 +418,64 @@ public sealed class DashboardSnapshotServiceTests
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||
{
|
||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
||||
}
|
||||
|
||||
public Task<bool> RevokeAsync(
|
||||
string keyId,
|
||||
DateTimeOffset revokedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> RotateAsync(
|
||||
string keyId,
|
||||
byte[] secretHash,
|
||||
DateTimeOffset rotatedUtc,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
||||
{
|
||||
public int ListCount { get; protected set; }
|
||||
|
||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ListCount++;
|
||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
|
||||
{
|
||||
public bool FailNext { get; set; }
|
||||
|
||||
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (FailNext)
|
||||
{
|
||||
FailNext = false;
|
||||
ListCount++;
|
||||
throw new InvalidOperationException("Simulated SQLite failure.");
|
||||
}
|
||||
|
||||
return base.ListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(
|
||||
string sessionId,
|
||||
string? clientIdentity,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -54,6 +55,20 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Build does not map dashboard routes when the dashboard is disabled.</summary>
|
||||
[Fact]
|
||||
public void Build_WhenDashboardEnabled_DashboardRoutesAllowAnonymousAccess()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app)
|
||||
.Where(endpoint => endpoint.RoutePattern.RawText?.StartsWith(
|
||||
"/dashboard",
|
||||
StringComparison.Ordinal) == true)
|
||||
.ToArray();
|
||||
|
||||
Assert.NotEmpty(endpoints);
|
||||
Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata<IAuthorizeData>() is not null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
||||
{
|
||||
@@ -89,6 +104,14 @@ public sealed class GatewayApplicationTests
|
||||
"MxGateway:Dashboard:PathBase",
|
||||
"dashboard",
|
||||
"MxGateway:Dashboard:PathBase must start with '/'.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:RequiredGroup",
|
||||
"",
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.")]
|
||||
[InlineData(
|
||||
"MxGateway:Ldap:AllowInsecureLdap",
|
||||
"false",
|
||||
"MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")]
|
||||
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
||||
string key,
|
||||
string value,
|
||||
|
||||
@@ -8,6 +8,7 @@ using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Grpc;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -178,6 +179,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
Service = new MxAccessGatewayService(
|
||||
sessionManager,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
mapper,
|
||||
eventStreamService,
|
||||
@@ -529,4 +531,33 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ public sealed class MxAccessGatewayServiceTests
|
||||
return new MxAccessGatewayService(
|
||||
sessionManager,
|
||||
identityAccessor ?? new GatewayRequestIdentityAccessor(),
|
||||
new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
new MxAccessGrpcMapper(),
|
||||
new FakeEventStreamService(sessionManager),
|
||||
@@ -445,6 +446,35 @@ public sealed class MxAccessGatewayServiceTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AllowAllConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken) => Task.FromResult<ConstraintFailure?>(null);
|
||||
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient(int processId) : IWorkerClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -34,6 +34,21 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that opening a session generates a correlation ID from the client name and session ID.</summary>
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_SetsInitialDefaultLease()
|
||||
{
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z"));
|
||||
GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800);
|
||||
SessionManager manager = CreateManager(
|
||||
new FakeSessionWorkerClientFactory(new FakeWorkerClient()),
|
||||
options: options,
|
||||
timeProvider: clock);
|
||||
|
||||
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||
|
||||
Assert.Equal(clock.GetUtcNow() + TimeSpan.FromMinutes(30), session.LeaseExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_GeneratesClientCorrelationIdFromClientNameAndSessionId()
|
||||
{
|
||||
@@ -82,6 +97,32 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that bulk subscribe forwards the command and returns subscription results.</summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_WhenSessionReady_RefreshesLease()
|
||||
{
|
||||
GatewaySession session = new(
|
||||
"session-lease-refresh",
|
||||
"mxaccess",
|
||||
"mxaccess-gateway-1-session-lease-refresh",
|
||||
"nonce",
|
||||
"client-1",
|
||||
"test-session",
|
||||
"client-correlation-1",
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromMinutes(30),
|
||||
DateTimeOffset.UtcNow - TimeSpan.FromHours(1));
|
||||
session.AttachWorkerClient(new FakeWorkerClient());
|
||||
session.MarkReady();
|
||||
DateTimeOffset? initialLease = session.LeaseExpiresAt;
|
||||
|
||||
await session.InvokeAsync(CreateCommand(MxCommandKind.Ping), CancellationToken.None);
|
||||
|
||||
Assert.True(session.LeaseExpiresAt > initialLease);
|
||||
Assert.True(session.LeaseExpiresAt > DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults()
|
||||
{
|
||||
@@ -322,6 +363,23 @@ public sealed class SessionManagerTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown closes all registered sessions.</summary>
|
||||
[Fact]
|
||||
public async Task CloseExpiredLeasesAsync_DoesNotCloseActiveEventSubscriber()
|
||||
{
|
||||
FakeWorkerClient workerClient = new();
|
||||
SessionManager manager = CreateManager(new FakeSessionWorkerClientFactory(workerClient));
|
||||
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
session.ExtendLease(now.AddSeconds(-1));
|
||||
using IDisposable eventSubscriber = session.AttachEventSubscriber(allowMultipleSubscribers: false);
|
||||
|
||||
int closedCount = await manager.CloseExpiredLeasesAsync(now, CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, closedCount);
|
||||
Assert.Equal(SessionState.Ready, session.State);
|
||||
Assert.Equal(0, workerClient.ShutdownCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_ClosesAllRegisteredSessions()
|
||||
{
|
||||
@@ -353,16 +411,20 @@ public sealed class SessionManagerTests
|
||||
ISessionWorkerClientFactory factory,
|
||||
ISessionRegistry? registry = null,
|
||||
GatewayMetrics? metrics = null,
|
||||
GatewayOptions? options = null)
|
||||
GatewayOptions? options = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
return new SessionManager(
|
||||
registry ?? new SessionRegistry(),
|
||||
factory,
|
||||
Options.Create(options ?? CreateOptions()),
|
||||
metrics ?? new GatewayMetrics());
|
||||
metrics ?? new GatewayMetrics(),
|
||||
timeProvider);
|
||||
}
|
||||
|
||||
private static GatewayOptions CreateOptions(int maxSessions = 64)
|
||||
private static GatewayOptions CreateOptions(
|
||||
int maxSessions = 64,
|
||||
int defaultLeaseSeconds = 1800)
|
||||
{
|
||||
return new GatewayOptions
|
||||
{
|
||||
@@ -370,6 +432,7 @@ public sealed class SessionManagerTests
|
||||
{
|
||||
DefaultCommandTimeoutSeconds = 30,
|
||||
MaxSessions = maxSessions,
|
||||
DefaultLeaseSeconds = defaultLeaseSeconds,
|
||||
},
|
||||
Worker = new WorkerOptions
|
||||
{
|
||||
@@ -586,4 +649,11 @@ public sealed class SessionManagerTests
|
||||
ShutdownReleased.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +143,36 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read loop faults the client when the pipe disconnects.</summary>
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess()
|
||||
{
|
||||
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||
FakeWorkerProcess process = new();
|
||||
await using WorkerClient client = CreateClient(
|
||||
pipePair,
|
||||
new WorkerClientOptions
|
||||
{
|
||||
EventChannelCapacity = 1,
|
||||
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||
HeartbeatCheckInterval = TimeSpan.FromSeconds(30),
|
||||
},
|
||||
processHandle: CreateProcessHandle(process));
|
||||
await CompleteHandshakeAsync(client, pipePair);
|
||||
|
||||
await pipePair.WorkerWriter.WriteAsync(
|
||||
CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange));
|
||||
await pipePair.WorkerWriter.WriteAsync(
|
||||
CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange));
|
||||
|
||||
await WaitUntilAsync(
|
||||
() => client.State == WorkerClientState.Faulted,
|
||||
TestTimeout);
|
||||
|
||||
Assert.Equal(1, process.KillCount);
|
||||
Assert.True(process.KillEntireProcessTree);
|
||||
Assert.True(process.HasExited);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenPipeDisconnects_FaultsClient()
|
||||
{
|
||||
@@ -200,6 +230,20 @@ public sealed class WorkerClientTests
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the read loop updates the last heartbeat and worker process when a heartbeat arrives.</summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhenOwnedWorkerStillRuns_KillsProcessBeforeDisposing()
|
||||
{
|
||||
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||
FakeWorkerProcess process = new();
|
||||
WorkerClient client = CreateClient(pipePair, processHandle: CreateProcessHandle(process));
|
||||
|
||||
await client.DisposeAsync().AsTask().WaitAsync(TestTimeout);
|
||||
|
||||
Assert.Equal(1, process.KillCount);
|
||||
Assert.True(process.KillEntireProcessTree);
|
||||
Assert.True(process.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess()
|
||||
{
|
||||
@@ -243,18 +287,28 @@ public sealed class WorkerClientTests
|
||||
private static WorkerClient CreateClient(
|
||||
PipePair pipePair,
|
||||
WorkerClientOptions? options = null,
|
||||
GatewayMetrics? metrics = null)
|
||||
GatewayMetrics? metrics = null,
|
||||
WorkerProcessHandle? processHandle = null)
|
||||
{
|
||||
WorkerFrameProtocolOptions frameOptions = new(SessionId);
|
||||
WorkerClientConnection connection = new(
|
||||
SessionId,
|
||||
Nonce,
|
||||
pipePair.GatewayStream,
|
||||
frameOptions);
|
||||
frameOptions,
|
||||
processHandle);
|
||||
|
||||
return new WorkerClient(connection, options, metrics);
|
||||
}
|
||||
|
||||
private static WorkerProcessHandle CreateProcessHandle(FakeWorkerProcess process)
|
||||
{
|
||||
return new WorkerProcessHandle(
|
||||
process,
|
||||
new WorkerProcessCommandLine("MxGateway.Worker.exe", []),
|
||||
DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
private static async Task CompleteHandshakeAsync(
|
||||
WorkerClient client,
|
||||
PipePair pipePair)
|
||||
@@ -454,4 +508,40 @@ public sealed class WorkerClientTests
|
||||
await GatewayStream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerProcess : IWorkerProcess
|
||||
{
|
||||
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public int Id { get; } = WorkerProcessId;
|
||||
|
||||
public bool HasExited { get; private set; }
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public int KillCount { get; private set; }
|
||||
|
||||
public bool KillEntireProcessTree { get; private set; }
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public void Kill(bool entireProcessTree)
|
||||
{
|
||||
KillCount++;
|
||||
KillEntireProcessTree = entireProcessTree;
|
||||
HasExited = true;
|
||||
ExitCode = -1;
|
||||
_exited.TrySetResult();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" },
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -62,7 +63,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: null,
|
||||
DisplayName: null,
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
listOutput,
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -90,7 +92,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: null,
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
TextWriter.Null,
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -125,7 +128,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: null,
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
rotateOutput,
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -160,7 +164,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal)),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -171,6 +176,41 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Assert.Equal(1, CountOccurrences(json, ApiKeySecret(apiKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateKeyAsync_WithConstraints_PersistsConstraints()
|
||||
{
|
||||
await using ServiceProvider services = BuildServices(CreateTempDatabasePath());
|
||||
ApiKeyAdminCliRunner runner = services.GetRequiredService<ApiKeyAdminCliRunner>();
|
||||
StringWriter output = new();
|
||||
|
||||
await runner.RunAsync(
|
||||
new ApiKeyAdminCommand(
|
||||
Kind: ApiKeyAdminCommandKind.CreateKey,
|
||||
Json: true,
|
||||
SqlitePath: null,
|
||||
Pepper: null,
|
||||
KeyId: "operator01",
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "metadata:read" },
|
||||
Constraints: ApiKeyConstraints.Empty with
|
||||
{
|
||||
BrowseSubtrees = ["Area1/*"],
|
||||
ReadAlarmOnly = true,
|
||||
}),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
string apiKey = ReadApiKey(output.ToString());
|
||||
ApiKeyVerificationResult verification = await services
|
||||
.GetRequiredService<IApiKeyVerifier>()
|
||||
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
|
||||
|
||||
Assert.True(verification.Succeeded);
|
||||
Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees);
|
||||
Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly);
|
||||
}
|
||||
|
||||
|
||||
private static async Task<string> CreateKeyAsync(ApiKeyAdminCliRunner runner, string keyId)
|
||||
{
|
||||
StringWriter output = new();
|
||||
@@ -182,7 +222,8 @@ public sealed class ApiKeyAdminCliRunnerTests
|
||||
Pepper: null,
|
||||
KeyId: keyId,
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" }),
|
||||
Scopes: new HashSet<string>(StringComparer.Ordinal) { "session:open" },
|
||||
Constraints: ApiKeyConstraints.Empty),
|
||||
output,
|
||||
CancellationToken.None);
|
||||
|
||||
|
||||
@@ -55,6 +55,42 @@ public sealed class ApiKeyAdminCommandLineParserTests
|
||||
/// <summary>
|
||||
/// Verifies create key without display name returns error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parse_CreateKeyCommand_ReturnsConstraints()
|
||||
{
|
||||
ApiKeyAdminParseResult result = ApiKeyAdminCommandLineParser.Parse(
|
||||
[
|
||||
"apikey",
|
||||
"create-key",
|
||||
"--key-id",
|
||||
"operator01",
|
||||
"--display-name",
|
||||
"Operator",
|
||||
"--read-subtree",
|
||||
"Area1/*",
|
||||
"--read-subtree",
|
||||
"Area2/*",
|
||||
"--write-tag-glob",
|
||||
"Pump_*",
|
||||
"--max-write-classification",
|
||||
"2",
|
||||
"--browse-subtree",
|
||||
"Area1/*",
|
||||
"--read-alarm-only",
|
||||
"--read-historized-only"
|
||||
]);
|
||||
|
||||
Assert.True(result.IsApiKeyCommand);
|
||||
Assert.NotNull(result.Command);
|
||||
ApiKeyConstraints constraints = result.Command.Constraints;
|
||||
Assert.Equal(["Area1/*", "Area2/*"], constraints.ReadSubtrees);
|
||||
Assert.Equal(["Pump_*"], constraints.WriteTagGlobs);
|
||||
Assert.Equal(2, constraints.MaxWriteClassification);
|
||||
Assert.Equal(["Area1/*"], constraints.BrowseSubtrees);
|
||||
Assert.True(constraints.ReadAlarmOnly);
|
||||
Assert.True(constraints.ReadHistorizedOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CreateKeyWithoutDisplayName_ReturnsError()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user