Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
@@ -19,4 +20,20 @@ public interface IMxGatewayCliClient : IAsyncDisposable
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken);
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken);
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken);
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
}
@@ -1,40 +1,84 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter(MxGatewayClient client) : IMxGatewayCliClient
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{
private readonly MxGatewayClient _client;
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
public MxGatewayCliClientAdapter(MxGatewayClient client)
{
_client = client;
_galaxyClient = new Lazy<GalaxyRepositoryClient>(
() => GalaxyRepositoryClient.Create(_client.Options));
}
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken)
{
return client.OpenSessionRawAsync(request, cancellationToken);
return _client.OpenSessionRawAsync(request, cancellationToken);
}
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken)
{
return client.CloseSessionRawAsync(request, cancellationToken);
return _client.CloseSessionRawAsync(request, cancellationToken);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken)
{
return client.InvokeAsync(request, cancellationToken);
return _client.InvokeAsync(request, cancellationToken);
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken)
{
return client.StreamEventsAsync(request, cancellationToken);
return _client.StreamEventsAsync(request, cancellationToken);
}
public ValueTask DisposeAsync()
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
return client.DisposeAsync();
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
public async ValueTask DisposeAsync()
{
if (_galaxyClient.IsValueCreated)
{
await _galaxyClient.Value.DisposeAsync().ConfigureAwait(false);
}
await _client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using Google.Protobuf;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
@@ -70,7 +71,7 @@ public static class MxGatewayClientCli
}
await using IMxGatewayCliClient client = clientFactory(CreateOptions(arguments));
using CancellationTokenSource cancellation = CreateCancellation(arguments);
using CancellationTokenSource cancellation = CreateCancellation(arguments, command);
return command switch
{
@@ -98,6 +99,14 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"smoke" => await SmokeAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-test-connection" => await GalaxyTestConnectionAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-last-deploy" => await GalaxyLastDeployAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-discover" => await GalaxyDiscoverAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-watch" => await GalaxyWatchAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
_ => WriteUnknownCommand(command, standardError),
};
}
@@ -168,9 +177,18 @@ public static class MxGatewayClientCli
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments)
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
// Long-running streaming commands run until Ctrl+C / cancellation by default;
// a caller-supplied --timeout still applies if present.
bool isLongRunning = command is "galaxy-watch";
string? rawTimeout = arguments.GetOptional("timeout");
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
{
return cancellation;
}
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
cancellation.CancelAfter(timeout);
return cancellation;
@@ -766,6 +784,146 @@ public static class MxGatewayClientCli
.ToMxValue();
}
private static Task<int> GalaxyTestConnectionAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return WriteReplyAsync(
client.GalaxyTestConnectionAsync(new TestConnectionRequest(), cancellationToken),
arguments,
output);
}
private static Task<int> GalaxyLastDeployAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return WriteReplyAsync(
client.GalaxyGetLastDeployTimeAsync(new GetLastDeployTimeRequest(), cancellationToken),
arguments,
output);
}
private static async Task<int> GalaxyDiscoverAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
DiscoverHierarchyReply reply = await client.GalaxyDiscoverHierarchyAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
if (arguments.HasFlag("json"))
{
output.WriteLine(ProtobufJsonFormatter.Format(reply));
return 0;
}
output.WriteLine($"objects={reply.Objects.Count}");
foreach (GalaxyObject galaxyObject in reply.Objects)
{
output.WriteLine($"- gobject_id={galaxyObject.GobjectId} tag_name={galaxyObject.TagName} contained_name={galaxyObject.ContainedName} parent={galaxyObject.ParentGobjectId} attributes={galaxyObject.Attributes.Count}");
}
return 0;
}
private static async Task<int> GalaxyWatchAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
bool json = arguments.HasFlag("json");
uint maxEvents = arguments.GetUInt32("max-events", 0);
if (maxEvents > MaxAggregateEvents)
{
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
}
WatchDeployEventsRequest request = new();
string? lastSeen = arguments.GetOptional("last-seen-deploy-time");
if (!string.IsNullOrWhiteSpace(lastSeen))
{
DateTimeOffset parsed = DateTimeOffset.Parse(
lastSeen,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
request.LastSeenDeployTime = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(parsed);
}
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ConsoleCancelEventHandler handler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
try
{
linked.Cancel();
}
catch (ObjectDisposedException)
{
}
};
Console.CancelKeyPress += handler;
uint emitted = 0;
try
{
await foreach (DeployEvent deployEvent in client
.GalaxyWatchDeployEventsAsync(request, linked.Token)
.WithCancellation(linked.Token)
.ConfigureAwait(false))
{
if (json)
{
output.WriteLine(ProtobufJsonFormatter.Format(deployEvent));
}
else
{
output.WriteLine(FormatDeployEvent(deployEvent));
}
emitted++;
if (maxEvents > 0 && emitted >= maxEvents)
{
break;
}
}
}
catch (OperationCanceledException) when (linked.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// Ctrl+C-driven cancellation is a clean exit.
}
finally
{
Console.CancelKeyPress -= handler;
}
return 0;
}
private static string FormatDeployEvent(DeployEvent deployEvent)
{
string deployTime = deployEvent.TimeOfLastDeployPresent && deployEvent.TimeOfLastDeploy is not null
? deployEvent.TimeOfLastDeploy
.ToDateTimeOffset()
.ToString("O", CultureInfo.InvariantCulture)
: "<none>";
string observed = deployEvent.ObservedAt is not null
? deployEvent.ObservedAt
.ToDateTimeOffset()
.ToString("O", CultureInfo.InvariantCulture)
: "<unknown>";
return $"sequence={deployEvent.Sequence} observed_at={observed} time_of_last_deploy={deployTime} objects={deployEvent.ObjectCount} attributes={deployEvent.AttributeCount}";
}
private static int WriteUnknownCommand(string command, TextWriter standardError)
{
standardError.WriteLine($"Unknown command '{command}'.");
@@ -793,7 +951,11 @@ public static class MxGatewayClientCli
or "stream-events"
or "write"
or "write2"
or "smoke";
or "smoke"
or "galaxy-test-connection"
or "galaxy-last-deploy"
or "galaxy-discover"
or "galaxy-watch";
}
private static IReadOnlyList<string> ParseStringList(string value)
@@ -842,5 +1004,9 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
writer.WriteLine("mxgw-dotnet galaxy-test-connection [--json]");
writer.WriteLine("mxgw-dotnet galaxy-last-deploy [--json]");
writer.WriteLine("mxgw-dotnet galaxy-discover [--json]");
writer.WriteLine("mxgw-dotnet galaxy-watch [--last-seen-deploy-time <iso8601>] [--max-events <n>] [--json]");
}
}
@@ -0,0 +1,103 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
public Queue<Exception> TestConnectionExceptions { get; } = new();
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
TestConnectionCalls.Add((request, callOptions));
if (TestConnectionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(TestConnectionReply);
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
GetLastDeployTimeCalls.Add((request, callOptions));
if (GetLastDeployTimeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(GetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
DiscoverHierarchyCalls.Add((request, callOptions));
if (DiscoverHierarchyExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(DiscoverHierarchyReply);
}
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
public List<DeployEvent> WatchDeployEvents { get; } = [];
public Exception? WatchDeployEventsException { get; set; }
/// <summary>
/// When set, awaited before each event yield so tests can observe cancellation
/// mid-stream. Receives the call's cancellation token.
/// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
WatchDeployEventsCalls.Add((request, callOptions));
if (WatchDeployEventsException is not null)
{
throw WatchDeployEventsException;
}
foreach (DeployEvent deployEvent in WatchDeployEvents)
{
if (WatchDeployEventsBeforeYield is not null)
{
await WatchDeployEventsBeforeYield(callOptions.CancellationToken).ConfigureAwait(false);
}
callOptions.CancellationToken.ThrowIfCancellationRequested();
yield return deployEvent;
}
}
}
@@ -0,0 +1,301 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests
{
[Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync(cancellation.Token);
Assert.True(ok);
var call = Assert.Single(transport.TestConnectionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
[Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.False(ok);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.Null(deployTime);
Assert.Single(transport.GetLastDeployTimeCalls);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
{
Present = true,
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
};
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.NotNull(deployTime);
Assert.Equal(expected, deployTime!.Value);
}
[Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply
{
Objects =
{
new GalaxyObject
{
GobjectId = 12,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
BrowseName = "TestMachine_001/DelmiaReceiver",
ParentGobjectId = 5,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
MxDataType = 8,
DataTypeName = "MxString",
},
},
},
},
};
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
GalaxyObject obj = Assert.Single(objects);
Assert.Equal(12, obj.GobjectId);
Assert.Equal("DelmiaReceiver_001", obj.TagName);
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
Assert.Equal("DownloadPath", attribute.AttributeName);
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
}
[Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(cancellation.Token);
var call = Assert.Single(transport.DiscoverHierarchyCalls);
// The retry pipeline links the caller token with a per-call timeout token,
// so the transport sees the linked token rather than the caller's directly.
// Verify the link relationship by cancelling the caller and checking the
// call-side token reflects it.
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.True(ok);
Assert.Equal(2, transport.TestConnectionCalls.Count);
}
[Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync();
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deployTime),
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
TimeOfLastDeployPresent = true,
ObjectCount = 7,
AttributeCount = 42,
});
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
{
received.Add(evt);
}
DeployEvent only = Assert.Single(received);
Assert.Equal(1ul, only.Sequence);
Assert.Equal(7, only.ObjectCount);
Assert.Equal(42, only.AttributeCount);
Assert.True(only.TimeOfLastDeployPresent);
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
// No last_seen_deploy_time supplied → request leaves the field unset.
Assert.Null(call.Request.LastSeenDeployTime);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
for (int index = 1; index <= 3; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = (ulong)index,
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeployPresent = true,
ObjectCount = 10 + index,
AttributeCount = 100 + index,
});
}
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
{
received.Add(evt);
}
Assert.Equal(3, received.Count);
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.NotNull(call.Request.LastSeenDeployTime);
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
}
[Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Add many events; the test will cancel after the first.
for (int index = 1; index <= 10; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
}
using CancellationTokenSource cancellation = new();
// Cancel before the second yield by wiring the fake's pre-yield hook.
int yields = 0;
transport.WatchDeployEventsBeforeYield = _ =>
{
yields++;
if (yields >= 2)
{
cancellation.Cancel();
}
return Task.CompletedTask;
};
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (DeployEvent evt in client
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
{
received.Add(evt);
}
});
// The first event yields before cancellation triggers on the second pass.
Assert.Single(received);
Assert.Equal(1ul, received[0].Sequence);
}
[Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
Assert.Throws<ObjectDisposedException>(() =>
client.WatchDeployEventsAsync());
}
[Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
{
return new GalaxyRepositoryClient(transport.Options, transport);
}
private static FakeGalaxyRepositoryTransport CreateTransport()
{
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -1,5 +1,7 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Client.Cli;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
@@ -170,6 +172,171 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("session-fixture", closeRequest.SessionId);
}
[Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new()
{
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-test-connection",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
Assert.Contains("\"ok\": true", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDiscoverHierarchyReply = new DiscoverHierarchyReply
{
Objects =
{
new GalaxyObject
{
GobjectId = 7,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
},
},
},
},
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-discover",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyDiscoverHierarchyRequests);
string text = output.ToString();
Assert.Contains("objects=1", text);
Assert.Contains("DelmiaReceiver_001", text);
Assert.Contains("attributes=1", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deploy),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
TimeOfLastDeployPresent = true,
ObjectCount = 5,
AttributeCount = 17,
});
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 2,
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeployPresent = true,
ObjectCount = 6,
AttributeCount = 18,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--last-seen-deploy-time",
"2026-04-28T14:00:00Z",
"--max-events",
"2",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
Assert.NotNull(request.LastSeenDeployTime);
string text = output.ToString();
Assert.Contains("sequence=1", text);
Assert.Contains("sequence=2", text);
Assert.Contains("objects=5", text);
Assert.Contains("attributes=18", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 42,
ObjectCount = 99,
AttributeCount = 1024,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--max-events",
"1",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"sequence\": \"42\"", text);
Assert.Contains("\"objectCount\": 99", text);
}
private sealed class FakeCliClient : IMxGatewayCliClient
{
public Queue<MxCommandReply> InvokeReplies { get; } = new();
@@ -237,5 +404,58 @@ public sealed class MxGatewayClientCliTests
yield return gatewayEvent;
}
}
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
GalaxyTestConnectionRequests.Add(request);
return Task.FromResult(GalaxyTestConnectionReply);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
GalaxyGetLastDeployTimeRequests.Add(request);
return Task.FromResult(GalaxyGetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
GalaxyDiscoverHierarchyRequests.Add(request);
return Task.FromResult(GalaxyDiscoverHierarchyReply);
}
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
GalaxyWatchDeployEventsRequests.Add(request);
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return deployEvent;
}
}
}
}
@@ -0,0 +1,308 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
/// interceptor and require the <c>metadata:read</c> scope server-side.
/// </summary>
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
internal GalaxyRepositoryClient(
MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
_channel = null;
}
private GalaxyRepositoryClient(
GrpcChannel channel,
IGalaxyRepositoryClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
}
public MxGatewayClientOptions Options { get; }
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
});
return new GalaxyRepositoryClient(
channel,
new GrpcGalaxyRepositoryClientTransport(
options,
new GalaxyRepository.GalaxyRepositoryClient(channel)));
}
/// <summary>
/// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server.
/// </summary>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
TestConnectionReply reply = await TestConnectionRawAsync(
new TestConnectionRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Ok;
}
public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded.
/// </summary>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
new GetLastDeployTimeRequest(),
cancellationToken)
.ConfigureAwait(false);
if (!reply.Present || reply.TimeOfLastDeploy is null)
{
return null;
}
return reply.TimeOfLastDeploy.ToDateTime();
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
/// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service.
/// </summary>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Objects;
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
/// suppress the bootstrap when the caller already holds the current deploy time.
/// </summary>
/// <remarks>
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
/// stream is interrupted the caller must reopen it; the server does not guarantee
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
WatchDeployEventsRequest request = new();
if (lastSeenDeployTime is { } seen)
{
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
}
return WatchDeployEventsRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return WatchDeployEventsCoreAsync(request, cancellationToken);
}
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
WatchDeployEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (DeployEvent deployEvent in _transport
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return deployEvent;
}
}
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -0,0 +1,127 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
public async Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.TestConnectionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.GetLastDeployTimeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.DiscoverHierarchyAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<DeployEvent> call = RawClient.WatchDeployEvents(request, callOptions);
IAsyncStreamReader<DeployEvent> responseStream = call.ResponseStream;
while (true)
{
DeployEvent? deployEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
deployEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return deployEvent;
}
}
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
return WatchDeployEventsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,27 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal interface IGalaxyRepositoryClientTransport
{
MxGatewayClientOptions Options { get; }
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions);
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions);
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions);
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions);
}
+76
View File
@@ -133,6 +133,82 @@ optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
output redacts API keys supplied through `--api-key`.
## Galaxy Repository Browse
`GalaxyRepositoryClient` is a separate read-only wrapper around the
`GalaxyRepository` gRPC service exposed by the same gateway. It shares the API
key auth interceptor with `MxGatewayClient` and requires the `metadata:read`
scope server-side. Use it to probe the ZB SQL connection, watch
`time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy
object hierarchy plus each object's dynamic attributes.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
}
}
```
The CLI exposes the same operations:
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
```
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
server emits a bootstrap event with the current state on subscribe, then one
event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the
bootstrap when the caller already holds the current deploy time. Use the
monotonic `Sequence` field to detect dropped events: gaps mean the
per-subscriber server-side buffer overflowed and the caller should reconcile.
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The
caller is responsible for reopening the stream on transient failures.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
lastSeen,
cancellationToken))
{
Console.WriteLine(
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
{
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
}
}
```
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
```
Use TLS options for a secured gateway:
```powershell
+85
View File
@@ -84,6 +84,87 @@ goroutine cleanup. Raw protobuf messages remain available through the
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
read-only metadata-only browse over the AVEVA System Platform Galaxy
Repository. It uses the same API-key authentication as the MXAccess Gateway
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
`*GalaxyClient` that mirrors the connection-management conventions of
`Client`:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
return err
}
defer galaxy.Close()
ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)
```
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
bootstrap event with the current Galaxy state immediately on subscribe, then
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
bootstrap event when resuming from a known checkpoint:
```go
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
return err
}
for {
select {
case ev, ok := <-events:
if !ok {
return nil // stream completed (server EOF or ctx cancelled)
}
log.Printf("seq=%d objects=%d attrs=%d",
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
case streamErr := <-errs:
if streamErr != nil {
return streamErr // *GatewayError
}
case <-ctx.Done():
return ctx.Err()
}
}
```
Cancel the supplied context to tear down the stream cleanly. Both channels
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
in `*GatewayError`.
The CLI exposes the same RPC via `galaxy-watch`:
```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
```
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
one line per event in text mode or one JSON object per event with `-json`.
## CLI
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
@@ -98,6 +179,10 @@ go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -pl
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
```
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
+232 -1
View File
@@ -8,8 +8,10 @@ import (
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
@@ -88,6 +90,14 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runStreamEvents(ctx, args[1:], stdout, stderr)
case "smoke":
return runSmoke(ctx, args[1:], stdout, stderr)
case "galaxy-test-connection":
return runGalaxyTestConnection(ctx, args[1:], stdout, stderr)
case "galaxy-last-deploy":
return runGalaxyLastDeploy(ctx, args[1:], stdout, stderr)
case "galaxy-discover":
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
case "galaxy-watch":
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
default:
writeUsage(stderr)
return fmt.Errorf("unknown command %q", args[0])
@@ -651,5 +661,226 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke>")
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
}
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
options, err := common.resolved()
if err != nil {
return nil, options, err
}
client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
})
return client, options, err
}
func runGalaxyTestConnection(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-test-connection", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
ok, err := client.TestConnection(ctx)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, map[string]any{
"command": "galaxy-test-connection",
"options": options,
"ok": ok,
})
}
fmt.Fprintln(stdout, ok)
return nil
}
func runGalaxyLastDeploy(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-last-deploy", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
deployTime, present, err := client.GetLastDeployTime(ctx)
if err != nil {
return err
}
if *jsonOutput {
payload := map[string]any{
"command": "galaxy-last-deploy",
"options": options,
"present": present,
}
if present {
payload["timeOfLastDeploy"] = deployTime.UTC().Format(time.RFC3339Nano)
}
return writeJSON(stdout, payload)
}
if !present {
fmt.Fprintln(stdout, "absent")
return nil
}
fmt.Fprintln(stdout, deployTime.UTC().Format(time.RFC3339Nano))
return nil
}
func runGalaxyDiscover(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-discover", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
objects, err := client.DiscoverHierarchy(ctx)
if err != nil {
return err
}
if *jsonOutput {
marshaled := make([]json.RawMessage, 0, len(objects))
for _, obj := range objects {
marshaled = append(marshaled, mustMarshalProto(obj))
}
return writeJSON(stdout, map[string]any{
"command": "galaxy-discover",
"options": options,
"objects": marshaled,
})
}
for _, obj := range objects {
fmt.Fprintf(stdout, "%d\t%s\t%s\t(attrs=%d)\n", obj.GetGobjectId(), obj.GetTagName(), obj.GetContainedName(), len(obj.GetAttributes()))
}
return nil
}
func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-watch", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
if err := flags.Parse(args); err != nil {
return err
}
var lastSeenPtr *time.Time
if *lastSeen != "" {
parsed, err := time.Parse(time.RFC3339, *lastSeen)
if err != nil {
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
}
lastSeenPtr = &parsed
}
client, _, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
streamCtx, cancelStream := context.WithCancel(signalCtx)
defer cancelStream()
events, errs, err := client.WatchDeployEvents(streamCtx, lastSeenPtr)
if err != nil {
return err
}
count := 0
for {
select {
case event, ok := <-events:
if !ok {
// Drain any terminal error before returning.
if streamErr, errOk := <-errs; errOk && streamErr != nil {
return streamErr
}
return nil
}
if *jsonOutput {
fmt.Fprintln(stdout, string(mustMarshalProto(event)))
} else {
fmt.Fprintln(stdout, formatDeployEvent(event))
}
count++
if *limit > 0 && count >= *limit {
cancelStream()
return nil
}
case streamErr, ok := <-errs:
if !ok {
return nil
}
if streamErr != nil {
return streamErr
}
case <-signalCtx.Done():
cancelStream()
// Allow goroutine to drain.
for range events {
}
return nil
}
}
}
func formatDeployEvent(event *mxgateway.DeployEvent) string {
observed := ""
if ts := event.GetObservedAt(); ts != nil {
observed = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
deploy := "absent"
if event.GetTimeOfLastDeployPresent() {
if ts := event.GetTimeOfLastDeploy(); ts != nil {
deploy = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
}
return fmt.Sprintf(
"seq=%d observed=%s deploy=%s objects=%d attributes=%d",
event.GetSequence(),
observed,
deploy,
event.GetObjectCount(),
event.GetAttributeCount(),
)
}
+6 -2
View File
@@ -30,13 +30,17 @@ $env:Path = "$goPluginPath;$env:Path"
--go_opt=paths=source_relative `
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
"--go_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
& $protoc `
--proto_path=$protoRoot `
--go-grpc_out=$outputRoot `
--go-grpc_opt=paths=source_relative `
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
mxaccess_gateway.proto
"--go-grpc_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
galaxy_repository.proto
@@ -0,0 +1,778 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type TestConnectionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionRequest) Reset() {
*x = TestConnectionRequest{}
mi := &file_galaxy_repository_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionRequest) ProtoMessage() {}
func (x *TestConnectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionRequest.ProtoReflect.Descriptor instead.
func (*TestConnectionRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{0}
}
type TestConnectionReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionReply) Reset() {
*x = TestConnectionReply{}
mi := &file_galaxy_repository_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionReply) ProtoMessage() {}
func (x *TestConnectionReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionReply.ProtoReflect.Descriptor instead.
func (*TestConnectionReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{1}
}
func (x *TestConnectionReply) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
type GetLastDeployTimeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeRequest) Reset() {
*x = GetLastDeployTimeRequest{}
mi := &file_galaxy_repository_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeRequest) ProtoMessage() {}
func (x *GetLastDeployTimeRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeRequest.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{2}
}
type GetLastDeployTimeReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"`
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeReply) Reset() {
*x = GetLastDeployTimeReply{}
mi := &file_galaxy_repository_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeReply) ProtoMessage() {}
func (x *GetLastDeployTimeReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeReply.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{3}
}
func (x *GetLastDeployTimeReply) GetPresent() bool {
if x != nil {
return x.Present
}
return false
}
func (x *GetLastDeployTimeReply) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
type DiscoverHierarchyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyRequest) Reset() {
*x = DiscoverHierarchyRequest{}
mi := &file_galaxy_repository_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyRequest) ProtoMessage() {}
func (x *DiscoverHierarchyRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyRequest.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{4}
}
type DiscoverHierarchyReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyReply) Reset() {
*x = DiscoverHierarchyReply{}
mi := &file_galaxy_repository_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyReply) ProtoMessage() {}
func (x *DiscoverHierarchyReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyReply.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{5}
}
func (x *DiscoverHierarchyReply) GetObjects() []*GalaxyObject {
if x != nil {
return x.Objects
}
return nil
}
type WatchDeployEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Optional. When set, the bootstrap event is suppressed if the cached deploy
// time matches this value. Future events are still emitted normally.
LastSeenDeployTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=last_seen_deploy_time,json=lastSeenDeployTime,proto3" json:"last_seen_deploy_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WatchDeployEventsRequest) Reset() {
*x = WatchDeployEventsRequest{}
mi := &file_galaxy_repository_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WatchDeployEventsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WatchDeployEventsRequest) ProtoMessage() {}
func (x *WatchDeployEventsRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WatchDeployEventsRequest.ProtoReflect.Descriptor instead.
func (*WatchDeployEventsRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{6}
}
func (x *WatchDeployEventsRequest) GetLastSeenDeployTime() *timestamppb.Timestamp {
if x != nil {
return x.LastSeenDeployTime
}
return nil
}
type DeployEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Monotonically increasing per server start. Gaps indicate dropped events.
Sequence uint64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"`
// Server wall-clock when the cache observed the deploy.
ObservedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=observed_at,json=observedAt,proto3" json:"observed_at,omitempty"`
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
TimeOfLastDeployPresent bool `protobuf:"varint,4,opt,name=time_of_last_deploy_present,json=timeOfLastDeployPresent,proto3" json:"time_of_last_deploy_present,omitempty"`
ObjectCount int32 `protobuf:"varint,5,opt,name=object_count,json=objectCount,proto3" json:"object_count,omitempty"`
AttributeCount int32 `protobuf:"varint,6,opt,name=attribute_count,json=attributeCount,proto3" json:"attribute_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeployEvent) Reset() {
*x = DeployEvent{}
mi := &file_galaxy_repository_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeployEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeployEvent) ProtoMessage() {}
func (x *DeployEvent) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeployEvent.ProtoReflect.Descriptor instead.
func (*DeployEvent) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{7}
}
func (x *DeployEvent) GetSequence() uint64 {
if x != nil {
return x.Sequence
}
return 0
}
func (x *DeployEvent) GetObservedAt() *timestamppb.Timestamp {
if x != nil {
return x.ObservedAt
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeployPresent() bool {
if x != nil {
return x.TimeOfLastDeployPresent
}
return false
}
func (x *DeployEvent) GetObjectCount() int32 {
if x != nil {
return x.ObjectCount
}
return 0
}
func (x *DeployEvent) GetAttributeCount() int32 {
if x != nil {
return x.AttributeCount
}
return 0
}
type GalaxyObject struct {
state protoimpl.MessageState `protogen:"open.v1"`
GobjectId int32 `protobuf:"varint,1,opt,name=gobject_id,json=gobjectId,proto3" json:"gobject_id,omitempty"`
TagName string `protobuf:"bytes,2,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
ContainedName string `protobuf:"bytes,3,opt,name=contained_name,json=containedName,proto3" json:"contained_name,omitempty"`
BrowseName string `protobuf:"bytes,4,opt,name=browse_name,json=browseName,proto3" json:"browse_name,omitempty"`
ParentGobjectId int32 `protobuf:"varint,5,opt,name=parent_gobject_id,json=parentGobjectId,proto3" json:"parent_gobject_id,omitempty"`
IsArea bool `protobuf:"varint,6,opt,name=is_area,json=isArea,proto3" json:"is_area,omitempty"`
CategoryId int32 `protobuf:"varint,7,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"`
HostedByGobjectId int32 `protobuf:"varint,8,opt,name=hosted_by_gobject_id,json=hostedByGobjectId,proto3" json:"hosted_by_gobject_id,omitempty"`
TemplateChain []string `protobuf:"bytes,9,rep,name=template_chain,json=templateChain,proto3" json:"template_chain,omitempty"`
Attributes []*GalaxyAttribute `protobuf:"bytes,10,rep,name=attributes,proto3" json:"attributes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyObject) Reset() {
*x = GalaxyObject{}
mi := &file_galaxy_repository_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyObject) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyObject) ProtoMessage() {}
func (x *GalaxyObject) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyObject.ProtoReflect.Descriptor instead.
func (*GalaxyObject) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{8}
}
func (x *GalaxyObject) GetGobjectId() int32 {
if x != nil {
return x.GobjectId
}
return 0
}
func (x *GalaxyObject) GetTagName() string {
if x != nil {
return x.TagName
}
return ""
}
func (x *GalaxyObject) GetContainedName() string {
if x != nil {
return x.ContainedName
}
return ""
}
func (x *GalaxyObject) GetBrowseName() string {
if x != nil {
return x.BrowseName
}
return ""
}
func (x *GalaxyObject) GetParentGobjectId() int32 {
if x != nil {
return x.ParentGobjectId
}
return 0
}
func (x *GalaxyObject) GetIsArea() bool {
if x != nil {
return x.IsArea
}
return false
}
func (x *GalaxyObject) GetCategoryId() int32 {
if x != nil {
return x.CategoryId
}
return 0
}
func (x *GalaxyObject) GetHostedByGobjectId() int32 {
if x != nil {
return x.HostedByGobjectId
}
return 0
}
func (x *GalaxyObject) GetTemplateChain() []string {
if x != nil {
return x.TemplateChain
}
return nil
}
func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
if x != nil {
return x.Attributes
}
return nil
}
type GalaxyAttribute struct {
state protoimpl.MessageState `protogen:"open.v1"`
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyAttribute) Reset() {
*x = GalaxyAttribute{}
mi := &file_galaxy_repository_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyAttribute) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyAttribute) ProtoMessage() {}
func (x *GalaxyAttribute) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyAttribute.ProtoReflect.Descriptor instead.
func (*GalaxyAttribute) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{9}
}
func (x *GalaxyAttribute) GetAttributeName() string {
if x != nil {
return x.AttributeName
}
return ""
}
func (x *GalaxyAttribute) GetFullTagReference() string {
if x != nil {
return x.FullTagReference
}
return ""
}
func (x *GalaxyAttribute) GetMxDataType() int32 {
if x != nil {
return x.MxDataType
}
return 0
}
func (x *GalaxyAttribute) GetDataTypeName() string {
if x != nil {
return x.DataTypeName
}
return ""
}
func (x *GalaxyAttribute) GetIsArray() bool {
if x != nil {
return x.IsArray
}
return false
}
func (x *GalaxyAttribute) GetArrayDimension() int32 {
if x != nil {
return x.ArrayDimension
}
return 0
}
func (x *GalaxyAttribute) GetArrayDimensionPresent() bool {
if x != nil {
return x.ArrayDimensionPresent
}
return false
}
func (x *GalaxyAttribute) GetMxAttributeCategory() int32 {
if x != nil {
return x.MxAttributeCategory
}
return 0
}
func (x *GalaxyAttribute) GetSecurityClassification() int32 {
if x != nil {
return x.SecurityClassification
}
return 0
}
func (x *GalaxyAttribute) GetIsHistorized() bool {
if x != nil {
return x.IsHistorized
}
return false
}
func (x *GalaxyAttribute) GetIsAlarm() bool {
if x != nil {
return x.IsAlarm
}
return false
}
var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" +
"\n" +
"\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n" +
"\x15TestConnectionRequest\"%\n" +
"\x13TestConnectionReply\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\"\x1a\n" +
"\x18GetLastDeployTimeRequest\"}\n" +
"\x16GetLastDeployTimeReply\x12\x18\n" +
"\apresent\x18\x01 \x01(\bR\apresent\x12I\n" +
"\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\x1a\n" +
"\x18DiscoverHierarchyRequest\"V\n" +
"\x16DiscoverHierarchyReply\x12<\n" +
"\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\"i\n" +
"\x18WatchDeployEventsRequest\x12M\n" +
"\x15last_seen_deploy_time\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x12lastSeenDeployTime\"\xbb\x02\n" +
"\vDeployEvent\x12\x1a\n" +
"\bsequence\x18\x01 \x01(\x04R\bsequence\x12;\n" +
"\vobserved_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"observedAt\x12I\n" +
"\x13time_of_last_deploy\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\x12<\n" +
"\x1btime_of_last_deploy_present\x18\x04 \x01(\bR\x17timeOfLastDeployPresent\x12!\n" +
"\fobject_count\x18\x05 \x01(\x05R\vobjectCount\x12'\n" +
"\x0fattribute_count\x18\x06 \x01(\x05R\x0eattributeCount\"\x95\x03\n" +
"\fGalaxyObject\x12\x1d\n" +
"\n" +
"gobject_id\x18\x01 \x01(\x05R\tgobjectId\x12\x19\n" +
"\btag_name\x18\x02 \x01(\tR\atagName\x12%\n" +
"\x0econtained_name\x18\x03 \x01(\tR\rcontainedName\x12\x1f\n" +
"\vbrowse_name\x18\x04 \x01(\tR\n" +
"browseName\x12*\n" +
"\x11parent_gobject_id\x18\x05 \x01(\x05R\x0fparentGobjectId\x12\x17\n" +
"\ais_area\x18\x06 \x01(\bR\x06isArea\x12\x1f\n" +
"\vcategory_id\x18\a \x01(\x05R\n" +
"categoryId\x12/\n" +
"\x14hosted_by_gobject_id\x18\b \x01(\x05R\x11hostedByGobjectId\x12%\n" +
"\x0etemplate_chain\x18\t \x03(\tR\rtemplateChain\x12E\n" +
"\n" +
"attributes\x18\n" +
" \x03(\v2%.galaxy_repository.v1.GalaxyAttributeR\n" +
"attributes\"\xd7\x03\n" +
"\x0fGalaxyAttribute\x12%\n" +
"\x0eattribute_name\x18\x01 \x01(\tR\rattributeName\x12,\n" +
"\x12full_tag_reference\x18\x02 \x01(\tR\x10fullTagReference\x12 \n" +
"\fmx_data_type\x18\x03 \x01(\x05R\n" +
"mxDataType\x12$\n" +
"\x0edata_type_name\x18\x04 \x01(\tR\fdataTypeName\x12\x19\n" +
"\bis_array\x18\x05 \x01(\bR\aisArray\x12'\n" +
"\x0farray_dimension\x18\x06 \x01(\x05R\x0earrayDimension\x126\n" +
"\x17array_dimension_present\x18\a \x01(\bR\x15arrayDimensionPresent\x122\n" +
"\x15mx_attribute_category\x18\b \x01(\x05R\x13mxAttributeCategory\x127\n" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\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" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var (
file_galaxy_repository_proto_rawDescOnce sync.Once
file_galaxy_repository_proto_rawDescData []byte
)
func file_galaxy_repository_proto_rawDescGZIP() []byte {
file_galaxy_repository_proto_rawDescOnce.Do(func() {
file_galaxy_repository_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)))
})
return file_galaxy_repository_proto_rawDescData
}
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
(*GetLastDeployTimeRequest)(nil), // 2: galaxy_repository.v1.GetLastDeployTimeRequest
(*GetLastDeployTimeReply)(nil), // 3: galaxy_repository.v1.GetLastDeployTimeReply
(*DiscoverHierarchyRequest)(nil), // 4: galaxy_repository.v1.DiscoverHierarchyRequest
(*DiscoverHierarchyReply)(nil), // 5: galaxy_repository.v1.DiscoverHierarchyReply
(*WatchDeployEventsRequest)(nil), // 6: galaxy_repository.v1.WatchDeployEventsRequest
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
}
var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
8, // 1: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 2: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 3: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 5: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 6: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 7: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 8: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 9: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
1, // 10: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 11: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 12: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 13: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
10, // [10:14] is the sub-list for method output_type
6, // [6:10] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_galaxy_repository_proto_init() }
func file_galaxy_repository_proto_init() {
if File_galaxy_repository_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_galaxy_repository_proto_goTypes,
DependencyIndexes: file_galaxy_repository_proto_depIdxs,
MessageInfos: file_galaxy_repository_proto_msgTypes,
}.Build()
File_galaxy_repository_proto = out.File
file_galaxy_repository_proto_goTypes = nil
file_galaxy_repository_proto_depIdxs = nil
}
@@ -0,0 +1,261 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GalaxyRepository_TestConnection_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/TestConnection"
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryClient interface {
TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error)
GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
}
type galaxyRepositoryClient struct {
cc grpc.ClientConnInterface
}
func NewGalaxyRepositoryClient(cc grpc.ClientConnInterface) GalaxyRepositoryClient {
return &galaxyRepositoryClient{cc}
}
func (c *galaxyRepositoryClient) TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConnectionReply)
err := c.cc.Invoke(ctx, GalaxyRepository_TestConnection_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLastDeployTimeReply)
err := c.cc.Invoke(ctx, GalaxyRepository_GetLastDeployTime_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiscoverHierarchyReply)
err := c.cc.Invoke(ctx, GalaxyRepository_DiscoverHierarchy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GalaxyRepository_ServiceDesc.Streams[0], GalaxyRepository_WatchDeployEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchDeployEventsRequest, DeployEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryServer interface {
TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error)
GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
mustEmbedUnimplementedGalaxyRepositoryServer()
}
// UnimplementedGalaxyRepositoryServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGalaxyRepositoryServer struct{}
func (UnimplementedGalaxyRepositoryServer) TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error) {
return nil, status.Error(codes.Unimplemented, "method TestConnection not implemented")
}
func (UnimplementedGalaxyRepositoryServer) GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error) {
return nil, status.Error(codes.Unimplemented, "method GetLastDeployTime not implemented")
}
func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error) {
return nil, status.Error(codes.Unimplemented, "method DiscoverHierarchy not implemented")
}
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
// UnsafeGalaxyRepositoryServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GalaxyRepositoryServer will
// result in compilation errors.
type UnsafeGalaxyRepositoryServer interface {
mustEmbedUnimplementedGalaxyRepositoryServer()
}
func RegisterGalaxyRepositoryServer(s grpc.ServiceRegistrar, srv GalaxyRepositoryServer) {
// If the following call panics, it indicates UnimplementedGalaxyRepositoryServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GalaxyRepository_ServiceDesc, srv)
}
func _GalaxyRepository_TestConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConnectionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_TestConnection_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, req.(*TestConnectionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_GetLastDeployTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLastDeployTimeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_GetLastDeployTime_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, req.(*GetLastDeployTimeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_DiscoverHierarchy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DiscoverHierarchyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_DiscoverHierarchy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, req.(*DiscoverHierarchyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchDeployEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(GalaxyRepositoryServer).WatchDeployEvents(m, &grpc.GenericServerStream[WatchDeployEventsRequest, DeployEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
ServiceName: "galaxy_repository.v1.GalaxyRepository",
HandlerType: (*GalaxyRepositoryServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "TestConnection",
Handler: _GalaxyRepository_TestConnection_Handler,
},
{
MethodName: "GetLastDeployTime",
Handler: _GalaxyRepository_GetLastDeployTime_Handler,
},
{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchDeployEvents",
Handler: _GalaxyRepository_WatchDeployEvents_Handler,
ServerStreams: true,
},
},
Metadata: "galaxy_repository.proto",
}
+246
View File
@@ -0,0 +1,246 @@
package mxgateway
import (
"context"
"errors"
"io"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract
// access.
type RawGalaxyRepositoryClient = pb.GalaxyRepositoryClient
// Generated protobuf aliases for Galaxy Repository messages.
type (
TestConnectionRequest = pb.TestConnectionRequest
TestConnectionReply = pb.TestConnectionReply
GetLastDeployTimeRequest = pb.GetLastDeployTimeRequest
GetLastDeployTimeReply = pb.GetLastDeployTimeReply
DiscoverHierarchyRequest = pb.DiscoverHierarchyRequest
DiscoverHierarchyReply = pb.DiscoverHierarchyReply
GalaxyObject = pb.GalaxyObject
GalaxyAttribute = pb.GalaxyAttribute
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
DeployEvent = pb.DeployEvent
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
type RawDeployEventStream = grpc.ServerStreamingClient[pb.DeployEvent]
// GalaxyClient owns a gateway gRPC connection and exposes Galaxy Repository
// browse helpers. It mirrors the structure of Client and uses the same
// connection-management conventions.
type GalaxyClient struct {
conn *grpc.ClientConn
raw pb.GalaxyRepositoryClient
opts Options
}
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security,
// and dial-timeout behavior as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewGalaxyClient(conn, opts), nil
}
// NewGalaxyClient wraps an existing gRPC connection for Galaxy Repository
// access. The caller owns closing conn unless it calls Close on the returned
// GalaxyClient.
func NewGalaxyClient(conn *grpc.ClientConn, opts Options) *GalaxyClient {
return &GalaxyClient{
conn: conn,
raw: pb.NewGalaxyRepositoryClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity
// tests.
func (c *GalaxyClient) RawClient() RawGalaxyRepositoryClient {
return c.raw
}
// TestConnection probes the Galaxy Repository service. It returns the server's
// reported ok flag and a non-nil error only when the RPC itself fails.
func (c *GalaxyClient) TestConnection(ctx context.Context) (bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.TestConnection(callCtx, &pb.TestConnectionRequest{})
if err != nil {
return false, &GatewayError{Op: "galaxy test connection", Err: err}
}
return reply.GetOk(), nil
}
// GetLastDeployTime returns the Galaxy's last deploy timestamp. When the server
// reports present=false (no deploy recorded yet) the call returns
// (time.Time{}, false, nil). When present=true the timestamp is returned in
// UTC with present=true.
func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.GetLastDeployTime(callCtx, &pb.GetLastDeployTimeRequest{})
if err != nil {
return time.Time{}, false, &GatewayError{Op: "galaxy get last deploy time", Err: err}
}
if !reply.GetPresent() {
return time.Time{}, false, nil
}
ts := reply.GetTimeOfLastDeploy()
if ts == nil {
return time.Time{}, false, nil
}
return ts.AsTime(), true, nil
}
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied
// by the server.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
return reply.GetObjects(), nil
}
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
// that want direct control over Recv. The caller owns the returned stream's
// lifetime via ctx cancellation.
func (c *GalaxyClient) WatchDeployEventsRaw(ctx context.Context, req *WatchDeployEventsRequest) (RawDeployEventStream, error) {
if req == nil {
req = &pb.WatchDeployEventsRequest{}
}
stream, err := c.raw.WatchDeployEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy watch deploy events", Err: err}
}
return stream, nil
}
// WatchDeployEvents subscribes to Galaxy deploy events. The server emits a
// bootstrap event with the current state immediately on subscribe, then one
// event per new deploy. When lastSeenDeployTime is non-nil it is forwarded to
// the server to suppress the bootstrap event.
//
// The returned event channel is closed when the server completes the stream
// (io.EOF), when ctx is cancelled, or after a terminal error has been
// delivered on the error channel. The error channel is also closed once the
// stream tears down. Surfaced errors are wrapped in *GatewayError.
//
// Cancel ctx to tear the stream down cleanly.
func (c *GalaxyClient) WatchDeployEvents(
ctx context.Context,
lastSeenDeployTime *time.Time,
) (<-chan *DeployEvent, <-chan error, error) {
req := &pb.WatchDeployEventsRequest{}
if lastSeenDeployTime != nil {
req.LastSeenDeployTime = timestamppb.New(*lastSeenDeployTime)
}
stream, err := c.WatchDeployEventsRaw(ctx, req)
if err != nil {
return nil, nil, err
}
events := make(chan *DeployEvent, 16)
errs := make(chan error, 1)
go func() {
defer close(events)
defer close(errs)
for {
event, recvErr := stream.Recv()
if recvErr == nil {
select {
case events <- event:
case <-ctx.Done():
return
}
continue
}
if recvErr == io.EOF {
return
}
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
return
}
select {
case errs <- &GatewayError{Op: "galaxy watch deploy events", Err: recvErr}:
case <-ctx.Done():
}
return
}
}()
return events, errs, nil
}
// Close closes the underlying gRPC connection.
func (c *GalaxyClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
+427
View File
@@ -0,0 +1,427 @@
package mxgateway
import (
"context"
"errors"
"net"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
fake := &fakeGalaxyServer{
testReply: &pb.TestConnectionReply{Ok: true},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ok, err := client.TestConnection(context.Background())
if err != nil {
t.Fatalf("TestConnection() error = %v", err)
}
if !ok {
t.Fatalf("TestConnection() ok = false, want true")
}
if got := fake.testAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: false},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if !present {
t.Fatalf("present = false, want true")
}
if !got.Equal(want) {
t.Fatalf("time = %v, want %v", got, want)
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false (nil timestamp)")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
fake := &fakeGalaxyServer{
discoverReply: &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{
GobjectId: 1,
TagName: "TestMachine_001",
ContainedName: "TestMachine_001",
BrowseName: "TestMachine_001",
IsArea: false,
CategoryId: 7,
TemplateChain: []string{"$Object", "$AppObject"},
Attributes: []*pb.GalaxyAttribute{
{
AttributeName: "DownloadPath",
FullTagReference: "TestMachine_001.DownloadPath",
MxDataType: 8,
DataTypeName: "String",
},
},
},
{
GobjectId: 2,
TagName: "TestMachine_002",
ContainedName: "TestMachine_002",
ParentGobjectId: 1,
},
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objects, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy() error = %v", err)
}
if len(objects) != 2 {
t.Fatalf("len(objects) = %d, want 2", len(objects))
}
if objects[0].GetTagName() != "TestMachine_001" {
t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
}
if len(objects[0].GetAttributes()) != 1 {
t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
}
if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.TestConnection(context.Background())
if err == nil {
t.Fatal("TestConnection() error = nil, want error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
}
if gwErr.Op != "galaxy test connection" {
t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
}
}
func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{
Sequence: 1,
ObservedAt: timestamppb.New(bootstrap),
TimeOfLastDeploy: timestamppb.New(deploy1),
TimeOfLastDeployPresent: true,
ObjectCount: 10,
AttributeCount: 42,
},
{
Sequence: 2,
ObservedAt: timestamppb.New(deploy2),
TimeOfLastDeploy: timestamppb.New(deploy2),
TimeOfLastDeployPresent: true,
ObjectCount: 11,
AttributeCount: 44,
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events, errs, err := client.WatchDeployEvents(ctx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
got := make([]*DeployEvent, 0, 2)
loop:
for {
select {
case ev, ok := <-events:
if !ok {
break loop
}
got = append(got, ev)
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for events; got %d", len(got))
}
}
if len(got) != 2 {
t.Fatalf("len(events) = %d, want 2", len(got))
}
if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
}
if !got[0].GetTimeOfLastDeployPresent() {
t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
}
if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
}
if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
}
}
func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 7},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Drain everything.
loop:
for {
select {
case _, ok := <-events:
if !ok {
break loop
}
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout draining events")
}
}
if fake.watchRequest == nil {
t.Fatalf("server did not receive a request")
}
gotTs := fake.watchRequest.GetLastSeenDeployTime()
if gotTs == nil {
t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
}
if !gotTs.AsTime().Equal(lastSeen) {
t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
}
}
func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 1},
},
watchHoldOpen: true,
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
streamCtx, cancelStream := context.WithCancel(context.Background())
events, errs, err := client.WatchDeployEvents(streamCtx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Wait for the bootstrap event to arrive.
select {
case ev, ok := <-events:
if !ok {
t.Fatalf("events channel closed before delivering bootstrap")
}
if ev.GetSequence() != 1 {
t.Fatalf("got seq=%d, want 1", ev.GetSequence())
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for bootstrap event")
}
// Cancel the stream; both channels must close cleanly without delivering an error.
cancelStream()
deadline := time.After(2 * time.Second)
for events != nil || errs != nil {
select {
case _, ok := <-events:
if !ok {
events = nil
}
case errVal, ok := <-errs:
if !ok {
errs = nil
continue
}
if errVal != nil {
t.Fatalf("error after cancel: %v", errVal)
}
case <-deadline:
t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
}
}
}
func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterGalaxyRepositoryServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := DialGalaxy(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("DialGalaxy() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
s.testAuth = authorizationFromContext(ctx)
if s.failTest {
return nil, errors.New("simulated failure")
}
if s.testReply != nil {
return s.testReply, nil
}
return &pb.TestConnectionReply{Ok: true}, nil
}
func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
if s.deployReply != nil {
return s.deployReply, nil
}
return &pb.GetLastDeployTimeReply{Present: false}, nil
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
if s.discoverReply != nil {
return s.discoverReply, nil
}
return &pb.DiscoverHierarchyReply{}, nil
}
func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
s.watchRequest = req
for _, event := range s.watchEvents {
if err := stream.Send(event); err != nil {
return err
}
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
if s.watchHoldOpen {
<-stream.Context().Done()
}
return nil
}
+93
View File
@@ -67,6 +67,99 @@ cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
## Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
and the dynamic attributes on each object so they know which tag references to
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
the gateway and requires the `metadata:read` scope.
`GalaxyRepositoryClient` mirrors the `MxGatewayClient` pattern (caller-managed
or owned channel, `MxGatewayClientOptions`, blocking + async variants). Three
RPCs are exposed:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
boolean ok = galaxy.testConnection();
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
}
```
`getLastDeployTime` returns `Optional.empty()` when the server reports
`present=false`. `discoverHierarchy` returns the generated `GalaxyObject` proto
messages directly so callers can read all fields (including the nested
`GalaxyAttribute` list) without an extra DTO layer.
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
`--timeout`, and `--json` options as the gateway commands.
```powershell
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
sends a bootstrap `DeployEvent` immediately on subscribe and then one event
each time it observes a new `galaxy.time_of_last_deploy`. The `sequence` field
is monotonic per server start; gaps mean the per-subscriber buffer dropped
older events because the consumer was too slow.
The client exposes both an iterator-style adaptor over the async stub and an
observer-callback variant. Both honour the channel-level `streamTimeout`.
```java
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
while (events.hasNext()) {
DeployEvent event = events.next();
// event.getSequence(), event.getObservedAt(),
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
// event.getObjectCount(), event.getAttributeCount()
}
}
```
Pass an `Instant` for `lastSeenDeployTime` to suppress the bootstrap event when
the cached deploy time matches what the caller already has. `DeployEventStream`
implements `Iterator<DeployEvent>` and `AutoCloseable`; closing it cancels the
underlying gRPC call.
For callback delivery (e.g. when the consumer wants to drive a queue or
reactive pipeline), use the async variant:
```java
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
lastSeen,
new StreamObserver<>() {
@Override public void onNext(DeployEvent value) { /* ... */ }
@Override public void onError(Throwable t) { /* ... */ }
@Override public void onCompleted() { /* ... */ }
});
// later:
subscription.cancel(); // or subscription.close()
```
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`:
```powershell
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
```
## CLI Usage
Run the CLI through Gradle:
@@ -1,5 +1,7 @@
package com.dohertylan.mxgateway.cli;
import com.dohertylan.mxgateway.client.DeployEventStream;
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
import com.dohertylan.mxgateway.client.MxEventStream;
import com.dohertylan.mxgateway.client.MxGatewayClient;
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
@@ -7,15 +9,21 @@ import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
import com.dohertylan.mxgateway.client.MxGatewaySession;
import com.dohertylan.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
@@ -83,9 +91,195 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
return commandLine;
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GalaxyRepositoryClient connect() {
return GalaxyRepositoryClient.connect(common.resolved().toClientOptions());
}
}
@Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.")
static final class GalaxyTestConnectionCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
boolean ok = client.testConnection();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-test");
output.put("options", common.redactedJsonMap());
output.put("ok", ok);
out.println(jsonObject(output));
} else {
out.println(ok);
}
}
return 0;
}
}
@Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.")
static final class GalaxyDeployTimeCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
Optional<Instant> result = client.getLastDeployTime();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-deploy-time");
output.put("options", common.redactedJsonMap());
output.put("present", result.isPresent());
output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
out.println(jsonObject(output));
} else if (result.isPresent()) {
out.println(result.get());
} else {
out.println("(none)");
}
}
return 0;
}
}
@Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
static final class GalaxyDiscoverCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
List<GalaxyObject> objects = client.discoverHierarchy();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-discover");
output.put("options", common.redactedJsonMap());
output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
out.println(jsonObject(output));
} else {
out.printf("count=%d%n", objects.size());
for (GalaxyObject obj : objects) {
out.printf(" %s [%s] attrs=%d%n",
obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
}
}
}
return 0;
}
}
@Command(
name = "galaxy-watch",
description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
static final class GalaxyWatchCommand extends GalaxyCommand {
@Option(
names = "--last-seen-deploy-time",
description =
"Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
+ " deploy time matches.")
String lastSeenDeployTime;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
int limit;
@Override
public Integer call() {
Instant after = parseInstant(lastSeenDeployTime);
try (GalaxyRepositoryClient client = connect();
DeployEventStream events = client.watchDeployEvents(after)) {
PrintWriter out = common.spec.commandLine().getOut();
Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
Runtime.getRuntime().addShutdownHook(shutdownHook);
try {
int count = 0;
while (events.hasNext()) {
DeployEvent event = events.next();
if (json) {
out.println(protoJson(event));
} else {
out.printf(
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
event.getSequence(),
formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy())
: "(none)",
event.getObjectCount(),
event.getAttributeCount());
}
out.flush();
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
} finally {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// JVM is already shutting down.
}
}
}
return 0;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Instant.parse(value);
}
private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
}
}
private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("gobjectId", obj.getGobjectId());
values.put("tagName", obj.getTagName());
values.put("containedName", obj.getContainedName());
values.put("browseName", obj.getBrowseName());
values.put("parentGobjectId", obj.getParentGobjectId());
values.put("isArea", obj.getIsArea());
values.put("categoryId", obj.getCategoryId());
values.put("hostedByGobjectId", obj.getHostedByGobjectId());
values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
List<Map<String, Object>> attrs = new ArrayList<>();
for (GalaxyAttribute attr : obj.getAttributesList()) {
Map<String, Object> attrMap = new LinkedHashMap<>();
attrMap.put("attributeName", attr.getAttributeName());
attrMap.put("fullTagReference", attr.getFullTagReference());
attrMap.put("mxDataType", attr.getMxDataType());
attrMap.put("dataTypeName", attr.getDataTypeName());
attrMap.put("isArray", attr.getIsArray());
attrMap.put("arrayDimension", attr.getArrayDimension());
attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
attrMap.put("securityClassification", attr.getSecurityClassification());
attrMap.put("isHistorized", attr.getIsHistorized());
attrMap.put("isAlarm", attr.getIsAlarm());
attrs.add(attrMap);
}
values.put("attributes", attrs);
return values;
}
@Command(name = "version", description = "Prints the Java client version.")
public static final class VersionCommand implements Callable<Integer> {
@Spec
@@ -25,6 +25,7 @@ sourceSets {
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
include 'mxaccess_gateway.proto'
include 'mxaccess_worker.proto'
include 'galaxy_repository.proto'
}
}
}
@@ -0,0 +1,132 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
/**
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
* and are buffered in a bounded blocking queue; the iterator drains them.
* Closing the stream cancels the underlying gRPC call.
*/
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private volatile boolean closed;
private Object next;
DeployEventStream(int capacity) {
queue = new ArrayBlockingQueue<>(capacity);
}
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer() {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
DeployEventStream.this.requestStream = requestStream;
}
@Override
public void onNext(DeployEvent value) {
offer(value);
}
@Override
public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
offer(END);
return;
}
offer(error);
}
@Override
public void onCompleted() {
offer(END);
}
};
}
@Override
public boolean hasNext() {
if (next == END) {
return false;
}
if (next == null) {
next = take();
}
if (next instanceof RuntimeException runtimeException) {
next = END;
throw runtimeException;
}
if (next instanceof Throwable throwable) {
next = END;
throw new MxGatewayException(
"galaxy watch deploy events failed: " + throwable.getMessage(), throwable);
}
return next != END;
}
@Override
public DeployEvent next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Object value = next;
next = null;
return (DeployEvent) value;
}
@Override
public void close() {
closed = true;
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
offer(END);
}
private Object take() {
while (true) {
try {
return queue.take();
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new StatusRuntimeException(
Status.CANCELLED.withDescription("interrupted while reading deploy events"));
}
}
}
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return;
}
if (!queue.offer(value)) {
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client deploy event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
queue.offer(END);
}
}
}
@@ -0,0 +1,60 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
* deploy-event stream.
*/
public final class DeployEventSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void onNext(DeployEvent value) {
observer.onNext(value);
}
@Override
public void onError(Throwable error) {
observer.onError(error);
}
@Override
public void onCompleted() {
observer.onCompleted();
}
};
}
public void cancel() {
cancelled.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -0,0 +1,288 @@
package com.dohertylan.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import com.google.protobuf.Timestamp;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Instant;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
/**
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
* exposes the three metadata-only RPCs of the Galaxy Repository service in
* idiomatic Java types. Mirrors the constructor and option-handling style of
* {@link MxGatewayClient}.
*/
public final class GalaxyRepositoryClient implements AutoCloseable {
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub futureStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryStub asyncStub;
private GalaxyRepositoryClient(ManagedChannel channel, MxGatewayClientOptions options) {
this.ownedChannel = channel;
this.options = options;
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/**
* Construct a client over a caller-managed {@link Channel}. The caller owns
* channel lifecycle; {@link #close()} is a no-op for this constructor.
*/
public GalaxyRepositoryClient(Channel channel, MxGatewayClientOptions options) {
this.ownedChannel = null;
this.options = Objects.requireNonNull(options, "options");
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/** Build a new client and own its channel; close shuts the channel down. */
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
return new GalaxyRepositoryClient(createChannel(options), options);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
return withDeadline(blockingStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
return withDeadline(futureStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryStub rawAsyncStub() {
return asyncStub;
}
/**
* Invoke the {@code TestConnection} RPC and return the {@code ok} flag.
*/
public boolean testConnection() {
try {
TestConnectionReply reply = rawBlockingStub().testConnection(TestConnectionRequest.getDefaultInstance());
return reply.getOk();
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy test connection", error);
}
}
public CompletableFuture<Boolean> testConnectionAsync() {
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
.thenApply(TestConnectionReply::getOk);
}
/**
* Invoke the {@code GetLastDeployTime} RPC. Returns {@link Optional#empty()}
* when the server reports {@code present=false}.
*/
public Optional<Instant> getLastDeployTime() {
try {
GetLastDeployTimeReply reply =
rawBlockingStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance());
return mapDeployTime(reply);
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy get last deploy time", error);
}
}
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
.thenApply(GalaxyRepositoryClient::mapDeployTime);
}
/**
* Invoke the {@code DiscoverHierarchy} RPC and return the generated
* {@link GalaxyObject} messages directly. Callers can read every field of
* the proto message without an extra DTO layer.
*/
public List<GalaxyObject> discoverHierarchy() {
try {
DiscoverHierarchyReply reply =
rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance());
return reply.getObjectsList();
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy discover hierarchy", error);
}
}
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
return toCompletable(rawFutureStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance()))
.thenApply(DiscoverHierarchyReply::getObjectsList);
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub and consume
* results through a blocking iterator. Closing the returned stream cancels
* the underlying gRPC call.
*
* @param lastSeenDeployTime optional. When non-null, the bootstrap event is
* suppressed if the cached deploy time matches.
*/
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
DeployEventStream stream = new DeployEventStream(16);
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
return stream;
}
/**
* Iterator-style alias for {@link #watchDeployEvents(Instant)} matching the
* task-spec signature.
*/
public Iterator<DeployEvent> watchDeployEventsIterator(Instant lastSeenDeployTime) {
return watchDeployEvents(lastSeenDeployTime);
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub, dispatching
* each event to {@code observer}. The returned subscription is cancellable
* and {@link AutoCloseable}.
*/
public DeployEventSubscription watchDeployEventsAsync(
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
Objects.requireNonNull(observer, "observer");
DeployEventSubscription subscription = new DeployEventSubscription();
withStreamDeadline(rawAsyncStub())
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
return subscription;
}
private static WatchDeployEventsRequest buildWatchRequest(Instant lastSeenDeployTime) {
WatchDeployEventsRequest.Builder builder = WatchDeployEventsRequest.newBuilder();
if (lastSeenDeployTime != null) {
builder.setLastSeenDeployTime(Timestamp.newBuilder()
.setSeconds(lastSeenDeployTime.getEpochSecond())
.setNanos(lastSeenDeployTime.getNano())
.build());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
@Override
public void close() {
if (ownedChannel != null) {
ownedChannel.shutdown();
}
}
public void closeAndAwaitTermination() throws InterruptedException {
if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
}
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
if (!reply.getPresent()) {
return Optional.empty();
}
Timestamp ts = reply.getTimeOfLastDeploy();
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
}
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(16 * 1024 * 1024);
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure galaxy repository TLS", error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
target.whenComplete((ignoredResult, ignoredError) -> {
if (target.isCancelled()) {
source.cancel(true);
}
});
return target;
}
}
@@ -0,0 +1,327 @@
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.assertTrue;
import com.google.protobuf.Timestamp;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
final class GalaxyRepositoryClientTests {
@Test
void testConnectionReturnsOkAndSendsAuthMetadata() throws Exception {
AtomicReference<String> authorization = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, authorization);
GalaxyRepositoryClient client = g.client("mxgw_galaxy_secret")) {
assertTrue(client.testConnection());
assertEquals("Bearer mxgw_galaxy_secret", authorization.get());
}
}
@Test
void getLastDeployTimeReturnsEmptyWhenPresentFalse() throws Exception {
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(
GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertFalse(result.isPresent());
}
}
@Test
void getLastDeployTimeReturnsInstantWhenPresent() throws Exception {
Timestamp expected = Timestamp.newBuilder().setSeconds(1_700_000_000L).setNanos(123_000_000).build();
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder()
.setPresent(true)
.setTimeOfLastDeploy(expected)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertTrue(result.isPresent());
assertEquals(Instant.ofEpochSecond(1_700_000_000L, 123_000_000), result.get());
}
}
@Test
void discoverHierarchyReturnsObjectsAndAttributes() throws Exception {
AtomicReference<DiscoverHierarchyRequest> seenRequest = 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());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<GalaxyObject> objects = client.discoverHierarchy();
assertEquals(1, objects.size());
GalaxyObject only = objects.get(0);
assertEquals(7, only.getGobjectId());
assertEquals("Pump_001", only.getTagName());
assertEquals(1, only.getAttributesCount());
assertEquals("Pump_001.Speed", only.getAttributes(0).getFullTagReference());
assertTrue(only.getAttributes(0).getIsHistorized());
}
}
@Test
void watchDeployEventsReceivesEventsInOrder() throws Exception {
DeployEvent first = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_000L).build())
.setTimeOfLastDeploy(Timestamp.newBuilder().setSeconds(1_699_999_000L).build())
.setTimeOfLastDeployPresent(true)
.setObjectCount(42)
.setAttributeCount(123)
.build();
DeployEvent second = DeployEvent.newBuilder()
.setSequence(2)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_100L).build())
.setTimeOfLastDeployPresent(false)
.setObjectCount(43)
.setAttributeCount(125)
.build();
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onNext(first);
responseObserver.onNext(second);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(null)) {
assertTrue(stream.hasNext());
DeployEvent event1 = stream.next();
assertEquals(1L, event1.getSequence());
assertEquals(42, event1.getObjectCount());
assertTrue(event1.getTimeOfLastDeployPresent());
assertTrue(stream.hasNext());
DeployEvent event2 = stream.next();
assertEquals(2L, event2.getSequence());
assertFalse(event2.getTimeOfLastDeployPresent());
assertFalse(stream.hasNext());
}
}
}
@Test
void watchDeployEventsPropagatesLastSeenDeployTime() throws Exception {
AtomicReference<WatchDeployEventsRequest> seen = new AtomicReference<>();
Instant lastSeen = Instant.ofEpochSecond(1_700_000_000L, 250_000_000);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
seen.set(request);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(lastSeen)) {
assertFalse(stream.hasNext());
}
}
WatchDeployEventsRequest request = seen.get();
assertNotNull(request);
Timestamp expected = request.getLastSeenDeployTime();
assertEquals(lastSeen.getEpochSecond(), expected.getSeconds());
assertEquals(lastSeen.getNano(), expected.getNanos());
}
@Test
void watchDeployEventsClientCancellationTearsDownCleanly() throws Exception {
CountDownLatch cancelObserved = new CountDownLatch(1);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
io.grpc.stub.ServerCallStreamObserver<DeployEvent> serverObserver =
(io.grpc.stub.ServerCallStreamObserver<DeployEvent>) responseObserver;
serverObserver.setOnCancelHandler(cancelObserved::countDown);
DeployEvent bootstrap = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1L).build())
.build();
responseObserver.onNext(bootstrap);
// Server holds the stream open; cancellation must come from the client.
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
DeployEventStream stream = client.watchDeployEvents(null);
assertTrue(stream.hasNext());
assertEquals(1L, stream.next().getSequence());
stream.close();
assertTrue(
cancelObserved.await(5, TimeUnit.SECONDS),
"server should observe client-side cancellation");
assertFalse(stream.hasNext());
}
}
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
responseObserver.onNext(DiscoverHierarchyReply.getDefaultInstance());
responseObserver.onCompleted();
}
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onCompleted();
}
}
private record InProcessGalaxy(Server server, ManagedChannel channel) implements AutoCloseable {
static InProcessGalaxy start(
GalaxyRepositoryGrpc.GalaxyRepositoryImplBase service, AtomicReference<String> authorization)
throws Exception {
String serverName = "mxgw-galaxy-java-" + UUID.randomUUID();
ServerInterceptor interceptor = new ServerInterceptor() {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
return next.startCall(call, headers);
}
};
Server server = InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build();
return new InProcessGalaxy(server, channel);
}
GalaxyRepositoryClient client(String apiKey) {
return new GalaxyRepositoryClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey(apiKey)
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.build());
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
}
@@ -0,0 +1,641 @@
package galaxy_repository.v1;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
@io.grpc.stub.annotations.GrpcGenerated
public final class GalaxyRepositoryGrpc {
private GalaxyRepositoryGrpc() {}
public static final java.lang.String SERVICE_NAME = "galaxy_repository.v1.GalaxyRepository";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "TestConnection",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
GalaxyRepositoryGrpc.getTestConnectionMethod = getTestConnectionMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "TestConnection"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("TestConnection"))
.build();
}
}
}
return getTestConnectionMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "GetLastDeployTime",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
GalaxyRepositoryGrpc.getGetLastDeployTimeMethod = getGetLastDeployTimeMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "GetLastDeployTime"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("GetLastDeployTime"))
.build();
}
}
}
return getGetLastDeployTimeMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "DiscoverHierarchy",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
GalaxyRepositoryGrpc.getDiscoverHierarchyMethod = getDiscoverHierarchyMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "DiscoverHierarchy"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("DiscoverHierarchy"))
.build();
}
}
}
return getDiscoverHierarchyMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "WatchDeployEvents",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
GalaxyRepositoryGrpc.getWatchDeployEventsMethod = getWatchDeployEventsMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "WatchDeployEvents"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("WatchDeployEvents"))
.build();
}
}
}
return getWatchDeployEventsMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static GalaxyRepositoryStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub>() {
@java.lang.Override
public GalaxyRepositoryStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
};
return GalaxyRepositoryStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports all types of calls on the service
*/
public static GalaxyRepositoryBlockingV2Stub newBlockingV2Stub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub>() {
@java.lang.Override
public GalaxyRepositoryBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingV2Stub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static GalaxyRepositoryBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub>() {
@java.lang.Override
public GalaxyRepositoryBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static GalaxyRepositoryFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub>() {
@java.lang.Override
public GalaxyRepositoryFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
};
return GalaxyRepositoryFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public interface AsyncService {
/**
*/
default void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getTestConnectionMethod(), responseObserver);
}
/**
*/
default void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetLastDeployTimeMethod(), responseObserver);
}
/**
*/
default void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getDiscoverHierarchyMethod(), responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
default void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
}
}
/**
* Base class for the server implementation of the service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static abstract class GalaxyRepositoryImplBase
implements io.grpc.BindableService, AsyncService {
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return GalaxyRepositoryGrpc.bindService(this);
}
}
/**
* A stub to allow clients to do asynchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryStub
extends io.grpc.stub.AbstractAsyncStub<GalaxyRepositoryStub> {
private GalaxyRepositoryStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
/**
*/
public void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request, responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
}
}
/**
* A stub to allow clients to do synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingV2Stub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingV2Stub> {
private GalaxyRepositoryBlockingV2Stub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingV2Stub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>
watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do limited synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingStub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingStub> {
private GalaxyRepositoryBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public java.util.Iterator<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> watchDeployEvents(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do ListenableFuture-style rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryFutureStub
extends io.grpc.stub.AbstractFutureStub<GalaxyRepositoryFutureStub> {
private GalaxyRepositoryFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> testConnection(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getLastDeployTime(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> discoverHierarchy(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
}
}
private static final int METHODID_TEST_CONNECTION = 0;
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
private static final int METHODID_DISCOVER_HIERARCHY = 2;
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final AsyncService serviceImpl;
private final int methodId;
MethodHandlers(AsyncService serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_TEST_CONNECTION:
serviceImpl.testConnection((galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>) responseObserver);
break;
case METHODID_GET_LAST_DEPLOY_TIME:
serviceImpl.getLastDeployTime((galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>) responseObserver);
break;
case METHODID_DISCOVER_HIERARCHY:
serviceImpl.discoverHierarchy((galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>) responseObserver);
break;
case METHODID_WATCH_DEPLOY_EVENTS:
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
break;
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
}
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getTestConnectionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>(
service, METHODID_TEST_CONNECTION)))
.addMethod(
getGetLastDeployTimeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>(
service, METHODID_GET_LAST_DEPLOY_TIME)))
.addMethod(
getDiscoverHierarchyMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>(
service, METHODID_DISCOVER_HIERARCHY)))
.addMethod(
getWatchDeployEventsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
service, METHODID_WATCH_DEPLOY_EVENTS)))
.build();
}
private static abstract class GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
GalaxyRepositoryBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return galaxy_repository.v1.GalaxyRepositoryOuterClass.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("GalaxyRepository");
}
}
private static final class GalaxyRepositoryFileDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier {
GalaxyRepositoryFileDescriptorSupplier() {}
}
private static final class GalaxyRepositoryMethodDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final java.lang.String methodName;
GalaxyRepositoryMethodDescriptorSupplier(java.lang.String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (GalaxyRepositoryGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new GalaxyRepositoryFileDescriptorSupplier())
.addMethod(getTestConnectionMethod())
.addMethod(getGetLastDeployTimeMethod())
.addMethod(getDiscoverHierarchyMethod())
.addMethod(getWatchDeployEventsMethod())
.build();
}
}
}
return result;
}
}
+4
View File
@@ -12,6 +12,10 @@
{
"path": "mxaccess_worker.proto",
"role": "gateway_worker_ipc"
},
{
"path": "galaxy_repository.proto",
"role": "public_galaxy_repository"
}
],
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
+70
View File
@@ -98,6 +98,76 @@ MXAccess commands and preserve raw replies on typed command exceptions.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
## Galaxy Repository Browse
The `GalaxyRepositoryClient` wraps the read-only `GalaxyRepository` gRPC
service. It lets callers test connectivity to the AVEVA System Platform
Galaxy Repository (ZB SQL database), read the last deploy timestamp, and
enumerate the deployed object hierarchy plus each object's dynamic
attributes:
```python
from mxgateway import GalaxyRepositoryClient
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
if not await galaxy.test_connection():
raise RuntimeError("gateway cannot reach the Galaxy Repository DB")
last_deploy = await galaxy.get_last_deploy_time()
print(f"last deploy: {last_deploy}")
for obj in await galaxy.discover_hierarchy():
print(obj.tag_name, obj.contained_name)
for attr in obj.attributes:
print(" ", attr.attribute_name, "->", attr.full_tag_reference)
```
The methods return native Python types (`bool`, `datetime | None`, and a
`list[GalaxyObject]` of generated proto messages) so callers can index
into the hierarchy without learning the underlying stub class. The
service requires the `metadata:read` scope on the API key.
### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
subscription that emits the current cached deploy state immediately and
then one `DeployEvent` per new Galaxy deploy. `sequence` is monotonic per
gateway start; gaps mean events were dropped from the per-subscriber
buffer. Pass `last_seen_deploy_time` to suppress the bootstrap event when
the caller already has the current state cached:
```python
from datetime import datetime, timezone
from mxgateway import DeployEvent, GalaxyRepositoryClient
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
last_seen: datetime | None = None
async for event in galaxy.watch_deploy_events(last_seen_deploy_time=last_seen):
assert isinstance(event, DeployEvent)
print(
f"#{event.sequence} deploy={event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)} "
f"objects={event.object_count} attributes={event.attribute_count}"
)
if event.time_of_last_deploy_present:
last_seen = event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)
```
The method returns an async iterator yielding the generated `DeployEvent`
proto. Breaking out of the loop, calling `aclose()` on the iterator, or
cancelling the surrounding task closes the underlying gRPC stream
cleanly. The streaming RPC requires the same `metadata:read` scope as
the other Galaxy methods. The CLI does not currently expose a
streaming `watch-deploy-events` subcommand — use the library API
directly when subscribing to deploy events from Python.
## Authentication And TLS
`ClientOptions.api_key` adds this metadata to unary calls and streams:
+2 -1
View File
@@ -19,4 +19,5 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
"--python_out=$outputRoot" `
"--grpc_python_out=$outputRoot" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
+12
View File
@@ -2,6 +2,13 @@
from .auth import ApiKey, auth_metadata
from .client import GatewayClient
from .galaxy import GalaxyRepositoryClient
from .generated.galaxy_repository_pb2 import (
DeployEvent,
GalaxyAttribute,
GalaxyObject,
WatchDeployEventsRequest,
)
from .errors import (
MxAccessError,
MxGatewayAuthenticationError,
@@ -20,6 +27,10 @@ from .version import __version__
__all__ = [
"ApiKey",
"ClientOptions",
"DeployEvent",
"GalaxyAttribute",
"GalaxyObject",
"GalaxyRepositoryClient",
"GatewayClient",
"MxAccessError",
"MxGatewayAuthenticationError",
@@ -31,6 +42,7 @@ __all__ = [
"MxGatewayWorkerError",
"MxValueView",
"Session",
"WatchDeployEventsRequest",
"__version__",
"auth_metadata",
"from_mx_value",
+199
View File
@@ -0,0 +1,199 @@
"""Async Galaxy Repository client wrapper.
Wraps the read-only ``GalaxyRepository`` gRPC service exposed by the
MxAccess Gateway. The service lets callers test connectivity to the AVEVA
System Platform Galaxy Repository (ZB SQL database), read the last
deployment timestamp, and enumerate the deployed object hierarchy plus the
attributes on each object.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Sequence
from datetime import datetime
from typing import Any
import grpc
from google.protobuf.timestamp_pb2 import Timestamp
from .auth import merge_metadata
from .errors import 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
class GalaxyRepositoryClient:
"""Async client for the Galaxy Repository gRPC service."""
def __init__(
self,
*,
options: ClientOptions,
stub: Any,
channel: grpc.aio.Channel | None = None,
) -> None:
self.options = options
self.raw_stub = stub
self._channel = channel
self._closed = False
@classmethod
async def connect(
cls,
options: ClientOptions | None = None,
*,
endpoint: str | None = None,
api_key: str | None = None,
plaintext: bool = False,
ca_file: str | None = None,
server_name_override: str | None = None,
stub: Any | None = None,
) -> "GalaxyRepositoryClient":
"""Create a client with either a real async channel or a supplied fake stub."""
resolved = options or ClientOptions(
endpoint=endpoint or "",
api_key=api_key,
plaintext=plaintext,
ca_file=ca_file,
server_name_override=server_name_override,
)
if stub is not None:
return cls(options=resolved, stub=stub)
channel = create_channel(resolved)
return cls(
options=resolved,
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
channel=channel,
)
async def __aenter__(self) -> "GalaxyRepositoryClient":
return self
async def __aexit__(self, *_exc_info: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the owned gRPC channel."""
if self._closed:
return
if self._channel is not None:
await self._channel.close()
self._closed = True
async def test_connection(self) -> bool:
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
reply = await self._unary(
"test connection",
self.raw_stub.TestConnection,
galaxy_pb.TestConnectionRequest(),
)
return bool(reply.ok)
async def get_last_deploy_time(self) -> datetime | None:
"""Return the last Galaxy deploy timestamp or ``None`` when unset."""
reply = await self._unary(
"get last deploy time",
self.raw_stub.GetLastDeployTime,
galaxy_pb.GetLastDeployTimeRequest(),
)
if not reply.present:
return None
return reply.time_of_last_deploy.ToDatetime()
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)
def watch_deploy_events(
self,
last_seen_deploy_time: datetime | None = None,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> AsyncIterator[galaxy_pb.DeployEvent]:
"""Stream Galaxy deploy events.
On subscribe the gateway emits the current cached state and then one
event per new deploy time. ``sequence`` is monotonic per server start;
gaps mean events were dropped from the per-subscriber buffer. When
``last_seen_deploy_time`` is supplied and matches the current cached
deploy time the bootstrap event is suppressed.
"""
request = galaxy_pb.WatchDeployEventsRequest()
if last_seen_deploy_time is not None:
timestamp = Timestamp()
timestamp.FromDatetime(last_seen_deploy_time)
request.last_seen_deploy_time.CopyFrom(timestamp)
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
try:
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
return _canceling_iterator(call)
async def _unary(
self,
operation: str,
method: Any,
request: Any,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> Any:
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.call_timeout is not None:
kwargs["timeout"] = self.options.call_timeout
try:
call = method(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = method(request, **kwargs)
try:
return await call
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error(operation, error) from error
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
try:
async for event in call:
yield event
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error("watch deploy events", error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
@@ -21,7 +21,15 @@ sys.modules.setdefault("mxaccess_worker_pb2", mxaccess_worker_pb2)
mxaccess_worker_pb2_grpc = import_module(f"{__name__}.mxaccess_worker_pb2_grpc")
sys.modules.setdefault("mxaccess_worker_pb2_grpc", mxaccess_worker_pb2_grpc)
galaxy_repository_pb2 = import_module(f"{__name__}.galaxy_repository_pb2")
sys.modules.setdefault("galaxy_repository_pb2", galaxy_repository_pb2)
galaxy_repository_pb2_grpc = import_module(f"{__name__}.galaxy_repository_pb2_grpc")
sys.modules.setdefault("galaxy_repository_pb2_grpc", galaxy_repository_pb2_grpc)
__all__ = [
"galaxy_repository_pb2",
"galaxy_repository_pb2_grpc",
"mxaccess_gateway_pb2",
"mxaccess_gateway_pb2_grpc",
"mxaccess_worker_pb2",
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: galaxy_repository.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'galaxy_repository.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__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')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _globals)
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
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,244 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import galaxy_repository_pb2 as galaxy__repository__pb2
GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in galaxy_repository_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class GalaxyRepositoryStub(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.TestConnection = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
request_serializer=galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.TestConnectionReply.FromString,
_registered_method=True)
self.GetLastDeployTime = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
request_serializer=galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
_registered_method=True)
self.DiscoverHierarchy = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
request_serializer=galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
_registered_method=True)
self.WatchDeployEvents = channel.unary_stream(
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def TestConnection(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetLastDeployTime(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DiscoverHierarchy(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def WatchDeployEvents(self, request, context):
"""Server-stream of deploy events. The server emits the current state immediately
on subscribe (so clients can bootstrap their cache without waiting for the next
deploy), then emits one event each time the gateway's hierarchy cache observes
a new galaxy.time_of_last_deploy. The sequence field is monotonically
increasing per server start; gaps indicate the per-subscriber buffer dropped
older events because the client was too slow.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = {
'TestConnection': grpc.unary_unary_rpc_method_handler(
servicer.TestConnection,
request_deserializer=galaxy__repository__pb2.TestConnectionRequest.FromString,
response_serializer=galaxy__repository__pb2.TestConnectionReply.SerializeToString,
),
'GetLastDeployTime': grpc.unary_unary_rpc_method_handler(
servicer.GetLastDeployTime,
request_deserializer=galaxy__repository__pb2.GetLastDeployTimeRequest.FromString,
response_serializer=galaxy__repository__pb2.GetLastDeployTimeReply.SerializeToString,
),
'DiscoverHierarchy': grpc.unary_unary_rpc_method_handler(
servicer.DiscoverHierarchy,
request_deserializer=galaxy__repository__pb2.DiscoverHierarchyRequest.FromString,
response_serializer=galaxy__repository__pb2.DiscoverHierarchyReply.SerializeToString,
),
'WatchDeployEvents': grpc.unary_stream_rpc_method_handler(
servicer.WatchDeployEvents,
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class GalaxyRepository(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
@staticmethod
def TestConnection(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
galaxy__repository__pb2.TestConnectionReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetLastDeployTime(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DiscoverHierarchy(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def WatchDeployEvents(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
galaxy__repository__pb2.DeployEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+320
View File
@@ -0,0 +1,320 @@
"""Tests for the Galaxy Repository async client wrapper."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
import pytest
from google.protobuf.timestamp_pb2 import Timestamp
from mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
from mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
def test_galaxy_messages_import() -> None:
request = galaxy_pb.DiscoverHierarchyRequest()
obj = galaxy_pb.GalaxyObject(
gobject_id=42,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=10,
is_area=False,
category_id=4,
hosted_by_gobject_id=10,
template_chain=["$ApplicationObject", "$DelmiaReceiver"],
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
)
assert request.DESCRIPTOR is not None
assert obj.attributes[0].attribute_name == "DownloadPath"
assert hasattr(galaxy_pb_grpc, "GalaxyRepositoryStub")
@pytest.mark.asyncio
async def test_test_connection_returns_bool_and_sends_auth() -> None:
stub = FakeGalaxyStub()
stub.test_connection.replies = [galaxy_pb.TestConnectionReply(ok=True)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
result = await client.test_connection()
assert result is True
assert stub.test_connection.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.test_connection.requests[0], galaxy_pb.TestConnectionRequest)
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_datetime_when_present() -> None:
timestamp = Timestamp()
timestamp.FromDatetime(datetime(2025, 4, 1, 12, 30, 45, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [
galaxy_pb.GetLastDeployTimeReply(present=True, time_of_last_deploy=timestamp),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
when = await client.get_last_deploy_time()
assert when is not None
assert when.year == 2025
assert when.month == 4
assert when.day == 1
assert when.hour == 12
assert when.minute == 30
assert when.second == 45
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_none_when_not_present() -> None:
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [galaxy_pb.GetLastDeployTimeReply(present=False)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
assert await client.get_last_deploy_time() is None
@pytest.mark.asyncio
async def test_discover_hierarchy_returns_proto_objects() -> None:
stub = FakeGalaxyStub()
stub.discover_hierarchy.replies = [
galaxy_pb.DiscoverHierarchyReply(
objects=[
galaxy_pb.GalaxyObject(
gobject_id=1,
tag_name="TestMachine_001",
contained_name="TestMachine",
browse_name="TestMachine_001",
is_area=True,
),
galaxy_pb.GalaxyObject(
gobject_id=2,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=1,
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
),
],
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
objects = await client.discover_hierarchy()
assert isinstance(objects, list)
assert len(objects) == 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_watch_deploy_events_yields_events_in_order() -> None:
ts1 = Timestamp()
ts1.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
ts2 = Timestamp()
ts2.FromDatetime(datetime(2025, 4, 1, 11, 0, 0, tzinfo=timezone.utc))
events = [
galaxy_pb.DeployEvent(
sequence=1,
observed_at=ts1,
time_of_last_deploy=ts1,
time_of_last_deploy_present=True,
object_count=10,
attribute_count=42,
),
galaxy_pb.DeployEvent(
sequence=2,
observed_at=ts2,
time_of_last_deploy=ts2,
time_of_last_deploy_present=True,
object_count=11,
attribute_count=45,
),
]
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = list(events)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
received: list[DeployEvent] = []
async for event in client.watch_deploy_events():
received.append(event)
assert len(received) == 2
assert received[0].sequence == 1
assert received[1].sequence == 2
assert received[0].object_count == 10
assert received[1].attribute_count == 45
assert stub.watch_deploy_events.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.watch_deploy_events.requests[0], galaxy_pb.WatchDeployEventsRequest)
# No last_seen_deploy_time was passed, so the request should leave it unset.
assert not stub.watch_deploy_events.requests[0].HasField("last_seen_deploy_time")
@pytest.mark.asyncio
async def test_watch_deploy_events_propagates_last_seen_deploy_time() -> None:
last_seen = datetime(2025, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = []
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
async for _ in client.watch_deploy_events(last_seen_deploy_time=last_seen):
pass
request = stub.watch_deploy_events.requests[0]
assert isinstance(request, WatchDeployEventsRequest)
assert request.HasField("last_seen_deploy_time")
assert request.last_seen_deploy_time.ToDatetime(tzinfo=timezone.utc) == last_seen
@pytest.mark.asyncio
async def test_watch_deploy_events_cancellation_closes_stream() -> None:
ts = Timestamp()
ts.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
# Use a "blocking" stream that never yields more after the first event.
stub.watch_deploy_events = FakeStream(
[galaxy_pb.DeployEvent(sequence=1, observed_at=ts)],
block_after_replies=True,
)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
iterator = client.watch_deploy_events()
first = await iterator.__anext__()
assert first.sequence == 1
# Break the iterator by aclose() — this should drive the cancel path.
await iterator.aclose()
assert stub.watch_deploy_events.cancel_called is True
@pytest.mark.asyncio
async def test_close_marks_channel_closed_when_no_real_channel() -> None:
stub = FakeGalaxyStub()
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
await client.close()
# Idempotent: a second close should not raise.
await client.close()
class FakeGalaxyStub:
def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
self.watch_deploy_events = FakeStream([])
self.TestConnection = self.test_connection
self.GetLastDeployTime = self.get_last_deploy_time
self.DiscoverHierarchy = self.discover_hierarchy
@property
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
return self.watch_deploy_events
class FakeUnary:
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
timeout: float | None = None,
) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class FakeStream:
"""Sync-callable fake matching the gRPC unary-stream surface.
Calling the stub returns ``self`` (an async iterator). After exhausting the
seeded ``replies``, iteration either ends (default) or blocks indefinitely
(``block_after_replies=True``) so cancellation paths can be exercised.
"""
def __init__(
self,
replies: list[Any],
*,
block_after_replies: bool = False,
) -> None:
self.replies = list(replies)
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
self.cancel_called = False
self._block_after_replies = block_after_replies
def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
timeout: float | None = None,
) -> "FakeStream":
self.requests.append(request)
self.metadata = metadata
return self
def __aiter__(self) -> "FakeStream":
return self
async def __anext__(self) -> Any:
if self.replies:
return self.replies.pop(0)
if self._block_after_replies:
# Sleep forever until the consumer cancels us.
await asyncio.Event().wait()
raise StopAsyncIteration
def cancel(self) -> None:
self.cancel_called = True
+12
View File
@@ -595,6 +595,7 @@ dependencies = [
"clap",
"futures-util",
"mxgateway-client",
"prost-types",
"serde",
"serde_json",
"tokio",
@@ -886,6 +887,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -990,6 +1001,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
+70
View File
@@ -99,6 +99,76 @@ preserving the raw message for parity diagnostics. Command replies whose
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
retain the raw `MxCommandReply`.
## Galaxy Repository browse
The Galaxy Repository service exposes a read-only browse over the AVEVA System
Platform Galaxy Repository (ZB SQL database). It uses the same API-key auth as
the gateway service but requires the `metadata:read` scope on the server.
[`GalaxyClient`](src/galaxy.rs) wraps the generated Galaxy bindings the same
way [`GatewayClient`](src/client.rs) wraps the gateway bindings:
```rust
let mut galaxy = GalaxyClient::connect(
ClientOptions::new("http://localhost:5000")
.with_api_key(ApiKey::new(api_key)),
).await?;
let ok = galaxy.test_connection().await?;
let last_deploy = galaxy.get_last_deploy_time().await?; // Option<prost_types::Timestamp>
let objects = galaxy.discover_hierarchy().await?; // Vec<GalaxyObject>
```
`get_last_deploy_time` returns `None` when the server reports
`present = false`. `discover_hierarchy` returns the generated
`GalaxyObject` proto type (re-exported via
`mxgateway_client::generated::galaxy_repository::v1`) with all attributes
attached.
The CLI ships matching subcommands under `galaxy`:
```powershell
cargo run -p mxgw-cli -- galaxy test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
```
### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
server emits a bootstrap [`DeployEvent`](src/galaxy.rs) describing the
current cache state on subscribe, then one event each time the cached
`galaxy.time_of_last_deploy` changes. `sequence` is monotonic per server
start; gaps signal that the per-subscriber buffer dropped older events.
Pass `last_seen_deploy_time` to suppress the bootstrap event when the
client's cached deploy time matches the server's.
```rust
use futures_util::StreamExt;
let mut stream = galaxy.watch_deploy_events(None).await?;
while let Some(event) = stream.next().await {
let event = event?;
println!(
"seq={} objects={} attributes={}",
event.sequence, event.object_count, event.attribute_count,
);
}
// Drop the stream to cancel the gRPC call.
```
The matching CLI subcommand prints one line per event (`--json` switches to
one JSON object per event). `--last-seen-deploy-time` accepts an RFC3339
timestamp and is forwarded to the server. `--max-events` (default 0 = no
cap) lets you stop after a fixed number of events; otherwise the command
runs until the stream ends or `Ctrl+C` is pressed.
```powershell
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T15:30:00Z
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
+7 -1
View File
@@ -13,17 +13,23 @@ fn main() -> Result<(), Box<dyn Error>> {
let proto_root = repo_root.join("src/MxGateway.Contracts/Protos");
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
let worker_proto = proto_root.join("mxaccess_worker.proto");
let galaxy_proto = proto_root.join("galaxy_repository.proto");
let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("mxaccessgw-client-v1.protoset");
println!("cargo:rerun-if-changed={}", gateway_proto.display());
println!("cargo:rerun-if-changed={}", worker_proto.display());
println!("cargo:rerun-if-changed={}", galaxy_proto.display());
tonic_build::configure()
.build_server(true)
.build_client(true)
.file_descriptor_set_path(descriptor_path)
.compile_protos(
&[gateway_proto.as_path(), worker_proto.as_path()],
&[
gateway_proto.as_path(),
worker_proto.as_path(),
galaxy_proto.as_path(),
],
&[proto_root.as_path()],
)?;
+2 -1
View File
@@ -12,6 +12,7 @@ path = "src/main.rs"
clap = { workspace = true }
futures-util = { workspace = true }
mxgateway-client = { path = "../.." }
prost-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
+412 -2
View File
@@ -5,13 +5,14 @@ use std::time::Duration;
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use mxgateway_client::generated::mxaccess_gateway::v1::{
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
PingCommand, StreamEventsRequest,
};
use mxgateway_client::{
ApiKey, ClientOptions, Error, GatewayClient, MxValue, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION,
WORKER_PROTOCOL_VERSION,
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
use serde_json::json;
use serde_json::Value;
@@ -178,6 +179,51 @@ enum Command {
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Galaxy(GalaxyCommand),
}
#[derive(Debug, Subcommand)]
enum GalaxyCommand {
TestConnection {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
LastDeployTime {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
DiscoverHierarchy {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
/// Subscribe to the WatchDeployEvents server stream.
///
/// Prints one line per received event (or one JSON object with `--json`).
/// Runs until the stream ends, the server fails the call, or the
/// process is interrupted (Ctrl+C).
#[command(alias = "watch-deploy-events")]
Watch {
#[command(flatten)]
connection: ConnectionArgs,
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
/// supplied, the server suppresses the bootstrap event if its
/// cached deploy time matches this value.
#[arg(long)]
last_seen_deploy_time: Option<String>,
/// Optional cap on the number of events to print before exiting.
/// 0 (the default) means run until cancelled or the stream ends.
#[arg(long, default_value_t = 0)]
max_events: usize,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Args, Clone)]
@@ -465,6 +511,7 @@ async fn run(cli: Cli) -> Result<(), Error> {
.await?;
print_ok("write2", json);
}
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
Command::Smoke {
connection,
item,
@@ -514,6 +561,133 @@ async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
GatewayClient::connect(connection.options()).await
}
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
GalaxyClient::connect(connection.options()).await
}
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
match command {
GalaxyCommand::TestConnection { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let ok = client.test_connection().await?;
if json {
println!("{}", json!({ "ok": ok }));
} else if ok {
println!("ok");
} else {
println!("not ok");
}
}
GalaxyCommand::LastDeployTime { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let timestamp = client.get_last_deploy_time().await?;
match (json, timestamp) {
(true, Some(ts)) => {
println!(
"{}",
json!({
"present": true,
"seconds": ts.seconds,
"nanos": ts.nanos,
})
);
}
(true, None) => {
println!("{}", json!({ "present": false }));
}
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
(false, None) => println!("(absent)"),
}
}
GalaxyCommand::Watch {
connection,
last_seen_deploy_time,
max_events,
json,
} => {
let mut client = connect_galaxy(connection).await?;
let last_seen = last_seen_deploy_time
.as_deref()
.map(parse_rfc3339_timestamp)
.transpose()?;
let mut stream = client.watch_deploy_events(last_seen).await?;
let mut count = 0usize;
loop {
tokio::select! {
biased;
signal = tokio::signal::ctrl_c() => {
signal.map_err(|err| Error::InvalidArgument {
name: "ctrl_c".to_owned(),
detail: err.to_string(),
})?;
// Drop the stream below by breaking; tonic tears the
// gRPC call down cooperatively.
break;
}
next = stream.next() => {
let Some(event) = next else { break; };
let event = event?;
count += 1;
print_deploy_event(&event, json);
if max_events != 0 && count >= max_events {
break;
}
}
}
}
}
GalaxyCommand::DiscoverHierarchy { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let objects = client.discover_hierarchy().await?;
if json {
let payload: Vec<_> = objects
.iter()
.map(|object| {
json!({
"gobjectId": object.gobject_id,
"tagName": object.tag_name,
"containedName": object.contained_name,
"browseName": object.browse_name,
"parentGobjectId": object.parent_gobject_id,
"isArea": object.is_area,
"categoryId": object.category_id,
"hostedByGobjectId": object.hosted_by_gobject_id,
"templateChain": object.template_chain,
"attributes": object.attributes.iter().map(|attribute| json!({
"attributeName": attribute.attribute_name,
"fullTagReference": attribute.full_tag_reference,
"mxDataType": attribute.mx_data_type,
"dataTypeName": attribute.data_type_name,
"isArray": attribute.is_array,
"arrayDimension": attribute.array_dimension,
"arrayDimensionPresent": attribute.array_dimension_present,
"mxAttributeCategory": attribute.mx_attribute_category,
"securityClassification": attribute.security_classification,
"isHistorized": attribute.is_historized,
"isAlarm": attribute.is_alarm,
})).collect::<Vec<_>>(),
})
})
.collect();
println!("{}", json!({ "objects": payload }));
} else {
println!("{}", objects.len());
for object in &objects {
println!(
"{} {} {} ({} attribute(s))",
object.gobject_id,
object.tag_name,
object.browse_name,
object.attributes.len()
);
}
}
}
}
Ok(())
}
async fn session_for(
connection: ConnectionArgs,
session_id: String,
@@ -616,6 +790,208 @@ fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error>
Ok(parsed)
}
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
if use_json {
println!(
"{}",
json!({
"sequence": event.sequence,
"observedAt": event.observed_at.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
"objectCount": event.object_count,
"attributeCount": event.attribute_count,
})
);
} else {
let observed = event
.observed_at
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned());
let last_deploy = if event.time_of_last_deploy_present {
event
.time_of_last_deploy
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned())
} else {
"(absent)".to_owned()
};
println!(
"seq={} observed={} lastDeploy={} objects={} attributes={}",
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
);
}
}
/// Parse a small but practically-complete subset of RFC3339:
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
fn invalid(detail: impl Into<String>) -> Error {
Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: detail.into(),
}
}
let bytes = input.as_bytes();
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
return Err(invalid(format!(
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
)));
}
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
std::str::from_utf8(&bytes[start..start + len])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
};
let year = read_u32(0, 4)? as i32;
if bytes[4] != b'-' {
return Err(invalid("expected '-' after year"));
}
let month = read_u32(5, 2)?;
if bytes[7] != b'-' {
return Err(invalid("expected '-' after month"));
}
let day = read_u32(8, 2)?;
let hour = read_u32(11, 2)?;
if bytes[13] != b':' {
return Err(invalid("expected ':' after hour"));
}
let minute = read_u32(14, 2)?;
if bytes[16] != b':' {
return Err(invalid("expected ':' after minute"));
}
let second = read_u32(17, 2)?;
let mut cursor = 19usize;
let mut nanos: u32 = 0;
if cursor < bytes.len() && bytes[cursor] == b'.' {
cursor += 1;
let frac_start = cursor;
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
cursor += 1;
}
let frac_len = cursor - frac_start;
if frac_len == 0 {
return Err(invalid("expected fractional digits after '.'"));
}
let take = frac_len.min(9);
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid("invalid fractional digits"))?;
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
}
let mut offset_seconds: i64 = 0;
if cursor >= bytes.len() {
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
}
match bytes[cursor] {
b'Z' | b'z' => cursor += 1,
sign @ (b'+' | b'-') => {
cursor += 1;
if cursor + 5 > bytes.len() {
return Err(invalid("offset must be ±HH:MM"));
}
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset hour"))?;
if bytes[cursor + 2] != b':' {
return Err(invalid("offset must contain ':' between HH and MM"));
}
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset minute"))?;
cursor += 5;
let signed = if sign == b'-' { -1 } else { 1 };
offset_seconds = signed * (oh * 3600 + om * 60);
}
other => {
return Err(invalid(format!(
"unexpected timezone designator byte {other:?}"
)));
}
}
if cursor != bytes.len() {
return Err(invalid("trailing characters after timezone"));
}
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
let seconds = unix - offset_seconds;
Ok(prost_types::Timestamp {
seconds,
nanos: nanos as i32,
})
}
fn ymdhms_to_unix(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Result<i64, Error> {
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: "calendar component out of range".to_owned(),
});
}
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut max = DAYS[(month - 1) as usize];
if month == 2 && is_leap(year) {
max = 29;
}
if day > max {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: format!("day {day} out of range for month {month}/{year}"),
});
}
// Days from 1970-01-01 to year-month-day.
let mut total_days: i64 = 0;
if year >= 1970 {
for y in 1970..year {
total_days += if is_leap(y) { 366 } else { 365 };
}
} else {
for y in year..1970 {
total_days -= if is_leap(y) { 366 } else { 365 };
}
}
for m in 1..month {
let mut dim = DAYS[(m - 1) as usize];
if m == 2 && is_leap(year) {
dim = 29;
}
total_days += dim as i64;
}
total_days += (day - 1) as i64;
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
}
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
where
T: std::str::FromStr,
@@ -665,4 +1041,38 @@ mod tests {
assert_eq!(value["gatewayProtocolVersion"], 1);
assert_eq!(value["workerProtocolVersion"], 1);
}
#[test]
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
let parsed = Cli::try_parse_from([
"mxgw",
"galaxy",
"watch",
"--last-seen-deploy-time",
"2026-04-28T15:30:00Z",
"--max-events",
"5",
"--json",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_galaxy_watch_deploy_events_alias() {
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
assert_eq!(utc.seconds, plus.seconds);
assert_eq!(utc.nanos, 0);
assert_eq!(frac.seconds, utc.seconds);
assert_eq!(frac.nanos, 250_000_000);
}
}
+591
View File
@@ -0,0 +1,591 @@
//! Thin async wrapper for the `GalaxyRepository` gRPC service.
//!
//! The wrapper mirrors [`crate::client::GatewayClient`]: it owns a tonic
//! channel with the shared bearer-token interceptor and exposes the three
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
//! re-exported through [`crate::generated::galaxy_repository::v1`].
use std::fs;
use prost_types::Timestamp;
use tonic::codegen::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
use tonic::Request;
use crate::auth::AuthInterceptor;
use crate::error::Error;
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
TestConnectionRequest, WatchDeployEventsRequest,
};
use crate::options::ClientOptions;
/// Convenience alias for the generated Galaxy client wrapped in the
/// authentication interceptor.
pub type RawGalaxyClient = GalaxyRepositoryClient<InterceptedService<Channel, AuthInterceptor>>;
/// Stream of `DeployEvent` values returned by
/// [`GalaxyClient::watch_deploy_events`]. Mirrors
/// [`crate::client::EventStream`]: a boxed `Stream` whose `tonic::Status`
/// errors have already been mapped onto [`Error`]. Dropping the stream
/// cancels the underlying gRPC call.
pub type DeployEventStream = std::pin::Pin<
Box<dyn futures_core::Stream<Item = Result<DeployEvent, Error>> + Send + 'static>,
>;
/// Thin async wrapper around the generated Galaxy Repository gRPC client.
///
/// Construct it with [`GalaxyClient::connect`] using the same
/// [`ClientOptions`] that drive [`crate::client::GatewayClient`]. The
/// service is metadata-only (no sessions) and requires the `metadata:read`
/// API-key scope on the server side.
#[derive(Clone)]
pub struct GalaxyClient {
inner: RawGalaxyClient,
call_timeout: std::time::Duration,
stream_timeout: Option<std::time::Duration>,
}
impl GalaxyClient {
/// Connect to the gateway endpoint and build a Galaxy client. Mirrors
/// the TLS / plaintext / API-key handling used by `GatewayClient`.
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
let mut endpoint =
Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: source.to_string(),
}
})?;
endpoint = endpoint.connect_timeout(options.connect_timeout());
if !options.plaintext() {
let mut tls = ClientTlsConfig::new();
if let Some(server_name) = options.server_name_override() {
tls = tls.domain_name(server_name.to_owned());
}
if let Some(ca_file) = options.ca_file() {
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
}
endpoint = endpoint.tls_config(tls)?;
}
let channel = endpoint.connect().await?;
let interceptor = AuthInterceptor::new(options.api_key().cloned());
Ok(Self {
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
call_timeout: options.call_timeout(),
stream_timeout: options.stream_timeout(),
})
}
/// Build a [`GalaxyClient`] that talks through an existing tonic
/// 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());
Self {
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
call_timeout: options.call_timeout(),
stream_timeout: options.stream_timeout(),
}
}
/// Borrow the underlying generated client for advanced callers that need
/// access to features not surfaced by the wrapper.
pub fn raw_client(&mut self) -> &mut RawGalaxyClient {
&mut self.inner
}
/// Consume the wrapper and return the generated client.
pub fn into_inner(self) -> RawGalaxyClient {
self.inner
}
/// Probe the Galaxy Repository database connection. Returns the `ok`
/// flag from the server reply.
pub async fn test_connection(&mut self) -> Result<bool, Error> {
let response = self
.inner
.test_connection(self.unary_request(TestConnectionRequest {}))
.await?;
Ok(response.into_inner().ok)
}
/// Read the most recent Galaxy deployment timestamp. Returns `None`
/// when the server reports `present = false`.
pub async fn get_last_deploy_time(&mut self) -> Result<Option<Timestamp>, Error> {
let response = self
.inner
.get_last_deploy_time(self.unary_request(GetLastDeployTimeRequest {}))
.await?;
let reply = response.into_inner();
if reply.present {
Ok(reply.time_of_last_deploy)
} else {
Ok(None)
}
}
/// 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)
}
/// Subscribe to the server-streamed deploy-event feed.
///
/// The server emits a bootstrap event describing the current cache state
/// immediately on subscribe, then one event per observed change to
/// `galaxy.time_of_last_deploy`. When `last_seen_deploy_time` matches the
/// current cache, the bootstrap event is suppressed and the stream stays
/// idle until the next deploy.
///
/// Cancellation is cooperative: dropping the returned
/// [`DeployEventStream`] tears down the underlying gRPC call. Callers
/// drive consumption with `StreamExt::next` (or any other `Stream`
/// adapter).
pub async fn watch_deploy_events(
&mut self,
last_seen_deploy_time: Option<Timestamp>,
) -> Result<DeployEventStream, Error> {
let request = WatchDeployEventsRequest {
last_seen_deploy_time,
};
let response = self
.inner
.watch_deploy_events(self.stream_request(request))
.await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from)
});
Ok(Box::pin(stream))
}
fn unary_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
request.set_timeout(self.call_timeout);
request
}
fn stream_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
if let Some(timeout) = self.stream_timeout {
request.set_timeout(timeout);
}
request
}
}
#[cfg(test)]
mod tests {
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use futures_util::StreamExt;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream};
use tonic::transport::Server;
use tonic::{Request, Response, Status};
use super::*;
use crate::auth::ApiKey;
use crate::generated::galaxy_repository::v1::galaxy_repository_server::{
GalaxyRepository, GalaxyRepositoryServer,
};
use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
TestConnectionRequest, WatchDeployEventsRequest,
};
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
#[derive(Default)]
struct FakeState {
authorization: Mutex<Option<String>>,
present: Mutex<bool>,
last_deploy: Mutex<Option<Timestamp>>,
objects: Mutex<Vec<GalaxyObject>>,
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
watch_events: Mutex<Vec<DeployEvent>>,
watch_senders: Mutex<Vec<DeployEventTx>>,
watch_drop_signal: Mutex<Option<mpsc::UnboundedSender<()>>>,
}
#[derive(Clone)]
struct FakeGalaxy {
state: Arc<FakeState>,
}
#[tonic::async_trait]
impl GalaxyRepository for FakeGalaxy {
async fn test_connection(
&self,
request: Request<TestConnectionRequest>,
) -> Result<Response<TestConnectionReply>, Status> {
*self.state.authorization.lock().unwrap() = request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
Ok(Response::new(TestConnectionReply { ok: true }))
}
async fn get_last_deploy_time(
&self,
_request: Request<GetLastDeployTimeRequest>,
) -> Result<Response<GetLastDeployTimeReply>, Status> {
let present = *self.state.present.lock().unwrap();
let time = self.state.last_deploy.lock().unwrap().clone();
Ok(Response::new(GetLastDeployTimeReply {
present,
time_of_last_deploy: time,
}))
}
async fn discover_hierarchy(
&self,
_request: Request<DiscoverHierarchyRequest>,
) -> Result<Response<DiscoverHierarchyReply>, Status> {
Ok(Response::new(DiscoverHierarchyReply {
objects: self.state.objects.lock().unwrap().clone(),
}))
}
type WatchDeployEventsStream =
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
async fn watch_deploy_events(
&self,
request: Request<WatchDeployEventsRequest>,
) -> Result<Response<Self::WatchDeployEventsStream>, Status> {
self.state
.watch_requests
.lock()
.unwrap()
.push(request.into_inner());
let preset = self.state.watch_events.lock().unwrap().clone();
let (tx, rx) = mpsc::channel::<Result<DeployEvent, Status>>(16);
for event in preset {
tx.send(Ok(event))
.await
.map_err(|err| Status::internal(err.to_string()))?;
}
self.state.watch_senders.lock().unwrap().push(tx.clone());
let drop_signal = self.state.watch_drop_signal.lock().unwrap().clone();
let stream = ReceiverStream::new(rx);
let stream: Pin<Box<dyn tokio_stream::Stream<Item = _> + Send + 'static>> =
if let Some(signal) = drop_signal {
Box::pin(WatchStreamWithDropSignal {
inner: stream,
signal: Some(signal),
})
} else {
Box::pin(stream)
};
Ok(Response::new(stream))
}
}
/// Wraps the receiver stream so we can detect when the server-side stream
/// future is dropped (the client cancelled or dropped the stream). Used by
/// `watch_drop_tears_down_call`.
struct WatchStreamWithDropSignal<S> {
inner: S,
signal: Option<mpsc::UnboundedSender<()>>,
}
impl<S: tokio_stream::Stream + Unpin> tokio_stream::Stream for WatchStreamWithDropSignal<S> {
type Item = S::Item;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl<S> Drop for WatchStreamWithDropSignal<S> {
fn drop(&mut self) {
if let Some(signal) = self.signal.take() {
let _ = signal.send(());
}
}
}
async fn spawn_fake(state: Arc<FakeState>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let incoming = TcpListenerStream::new(listener);
let service = GalaxyRepositoryServer::new(FakeGalaxy { state });
tokio::spawn(async move {
Server::builder()
.add_service(service)
.serve_with_incoming(incoming)
.await
.unwrap();
});
format!("http://{address}")
}
#[tokio::test]
async fn test_connection_attaches_bearer_metadata_and_returns_ok() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(
ClientOptions::new(endpoint).with_api_key(ApiKey::new("mxgw_galaxy_secret")),
)
.await
.unwrap();
let ok = client.test_connection().await.unwrap();
assert!(ok);
assert_eq!(
state.authorization.lock().unwrap().as_deref(),
Some("Bearer mxgw_galaxy_secret")
);
}
#[tokio::test]
async fn get_last_deploy_time_returns_none_when_not_present() {
let state = Arc::new(FakeState::default());
*state.present.lock().unwrap() = false;
*state.last_deploy.lock().unwrap() = Some(Timestamp {
seconds: 1_700_000_000,
nanos: 0,
});
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let result = client.get_last_deploy_time().await.unwrap();
assert!(
result.is_none(),
"present=false on the wire must surface as None, got {result:?}"
);
}
#[tokio::test]
async fn get_last_deploy_time_returns_timestamp_when_present() {
let state = Arc::new(FakeState::default());
*state.present.lock().unwrap() = true;
*state.last_deploy.lock().unwrap() = Some(Timestamp {
seconds: 1_700_000_000,
nanos: 250_000_000,
});
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let result = client.get_last_deploy_time().await.unwrap();
let timestamp = result.expect("present=true should yield a timestamp");
assert_eq!(timestamp.seconds, 1_700_000_000);
assert_eq!(timestamp.nanos, 250_000_000);
}
#[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,
}],
}];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let objects = client.discover_hierarchy().await.unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(objects[0].tag_name, "DelmiaReceiver_001");
assert_eq!(objects[0].attributes.len(), 1);
assert_eq!(objects[0].attributes[0].attribute_name, "DownloadPath");
assert_eq!(
objects[0].attributes[0].full_tag_reference,
"DelmiaReceiver_001.DownloadPath"
);
}
#[tokio::test]
async fn watch_deploy_events_yields_events_in_order() {
let state = Arc::new(FakeState::default());
*state.watch_events.lock().unwrap() = vec![
DeployEvent {
sequence: 1,
observed_at: Some(Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
time_of_last_deploy: Some(Timestamp {
seconds: 1_699_000_000,
nanos: 0,
}),
time_of_last_deploy_present: true,
object_count: 12,
attribute_count: 80,
},
DeployEvent {
sequence: 2,
observed_at: Some(Timestamp {
seconds: 1_700_000_500,
nanos: 0,
}),
time_of_last_deploy: Some(Timestamp {
seconds: 1_699_500_000,
nanos: 0,
}),
time_of_last_deploy_present: true,
object_count: 13,
attribute_count: 85,
},
];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client.watch_deploy_events(None).await.unwrap();
let first = stream
.next()
.await
.expect("bootstrap event")
.expect("ok deploy event");
let second = stream
.next()
.await
.expect("second event")
.expect("ok deploy event");
assert_eq!(first.sequence, 1);
assert_eq!(first.object_count, 12);
assert_eq!(second.sequence, 2);
assert_eq!(second.object_count, 13);
assert!(first.time_of_last_deploy_present);
}
#[tokio::test]
async fn watch_deploy_events_propagates_last_seen_deploy_time() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let last_seen = Timestamp {
seconds: 1_699_999_999,
nanos: 123_456_789,
};
let stream = client.watch_deploy_events(Some(last_seen)).await.unwrap();
// Drop the stream right away — the test is solely about the request
// payload reaching the server.
drop(stream);
// Give the server task a moment to record the request.
for _ in 0..20 {
if !state.watch_requests.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let requests = state.watch_requests.lock().unwrap().clone();
assert_eq!(requests.len(), 1);
let recorded = requests[0]
.last_seen_deploy_time
.as_ref()
.expect("last_seen_deploy_time forwarded");
assert_eq!(recorded.seconds, last_seen.seconds);
assert_eq!(recorded.nanos, last_seen.nanos);
}
#[tokio::test]
async fn watch_deploy_events_drop_tears_down_call() {
let state = Arc::new(FakeState::default());
let (signal_tx, mut signal_rx) = mpsc::unbounded_channel();
*state.watch_drop_signal.lock().unwrap() = Some(signal_tx);
// Seed one event so the client gets something on the stream before we
// drop it; this proves the call is live.
*state.watch_events.lock().unwrap() = vec![DeployEvent {
sequence: 7,
observed_at: None,
time_of_last_deploy: None,
time_of_last_deploy_present: false,
object_count: 0,
attribute_count: 0,
}];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client.watch_deploy_events(None).await.unwrap();
let event = stream
.next()
.await
.expect("bootstrap event")
.expect("ok deploy event");
assert_eq!(event.sequence, 7);
// Dropping the client-side stream must trigger the server-side stream
// future to be dropped as well, signalling cancellation.
drop(stream);
let drop_seen = tokio::time::timeout(std::time::Duration::from_secs(2), signal_rx.recv())
.await
.expect("server-side stream future was not dropped within 2s");
assert!(
drop_seen.is_some(),
"drop signal channel closed unexpectedly"
);
}
}
+8
View File
@@ -13,3 +13,11 @@ pub mod mxaccess_worker {
tonic::include_proto!("mxaccess_worker.v1");
}
}
pub mod galaxy_repository {
pub mod v1 {
#![allow(clippy::large_enum_variant)]
tonic::include_proto!("galaxy_repository.v1");
}
}
+2
View File
@@ -7,6 +7,7 @@
pub mod auth;
pub mod client;
pub mod error;
pub mod galaxy;
pub mod generated;
pub mod options;
pub mod session;
@@ -16,6 +17,7 @@ pub mod version;
pub use auth::{ApiKey, AuthInterceptor};
pub use client::{EventStream, GatewayClient};
pub use error::{CommandError, Error};
pub use galaxy::{DeployEventStream, GalaxyClient};
pub use options::ClientOptions;
pub use session::Session;
pub use value::{MxArrayProjection, MxArrayValue, MxStatus, MxValue, MxValueProjection};
+266
View File
@@ -0,0 +1,266 @@
# Gateway Authentication
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail.
## Token Format
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash.
`ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip:
```csharp
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
if (string.IsNullOrWhiteSpace(authorizationHeader)
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string token = authorizationHeader[BearerPrefix.Length..].Trim();
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
```
A successful parse produces a `ParsedApiKey(KeyId, Secret)` record. The `IApiKeyParser` interface exists so verification consumers can be tested without depending on header-format details.
## Parsing and Secrets
### Secret generation
`ApiKeySecretGenerator.Generate()` is the single source of new secret material. It uses 32 bytes from `RandomNumberGenerator.Fill` and encodes with URL-safe base64 (no padding) so secrets can be embedded in headers without escaping:
```csharp
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
```
### Peppered hashing
`ApiKeySecretHasher` (registered behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved by `IConfiguration` lookup against the configured `PepperSecretName`:
```csharp
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using HMACSHA256 hmac = new(pepperBytes);
return hmac.ComputeHash(secretBytes);
}
```
The pepper is intentionally not stored alongside the hash: an attacker who exfiltrates only the SQLite file holds the hashes but lacks the keying material to brute-force candidate secrets, even if the stored hash algorithm and salt scheme are known. If the pepper is missing the hasher throws `ApiKeyPepperUnavailableException`, which the verifier converts to a distinct failure code rather than treating it as a credential mismatch.
## Verification
`ApiKeyVerifier` (`IApiKeyVerifier`) implements the verification flow:
1. Parse the `Authorization` header into a `ParsedApiKey`.
2. Look up the `ApiKeyRecord` by `KeyId` through `IApiKeyStore.FindByKeyIdAsync`.
3. Reject revoked records (`RevokedUtc is not null`).
4. Hash the presented secret with the configured pepper.
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles.
6. Record a `LastUsedUtc` timestamp via `MarkKeyUsedAsync` and return an `ApiKeyIdentity`.
```csharp
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
}
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes));
```
`ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client.
`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`) and is the type downstream authorization code consumes.
## Storage
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data.
### Connection factory
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and opens the connection in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning:
```csharp
public SqliteConnection CreateConnection()
{
string sqlitePath = options.Value.Authentication.SqlitePath;
string? directory = Path.GetDirectoryName(sqlitePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
SqliteConnectionStringBuilder builder = new()
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate
};
return new SqliteConnection(builder.ToString());
}
```
### Schema
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob, `display_name`, serialized `scopes`, and the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
- `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns.
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
### Read paths
`SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
```csharp
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
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));
}
```
### Write paths
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, and `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable.
### Audit trail
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`.
## Migration
Schema bring-up is centralised behind `IAuthStoreMigrator`. `SqliteAuthStoreMigrator` executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the v1 schema:
```csharp
if (existingVersion > SqliteAuthSchema.CurrentVersion)
{
throw new AuthStoreMigrationException(
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
```
`AuthStoreMigrationHostedService` runs the migrator at startup, but only when API-key authentication is enabled and `RunMigrationsOnStartup` is true. Operators who manage schema out-of-band can disable the hosted run and use the admin CLI's `init-db` command instead.
`AuthStoreMigrationException` is a sealed `InvalidOperationException` so it can be caught precisely without swallowing unrelated failures.
## Admin CLI
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). `ApiKeyAdminCliRunner` then executes the command, runs the migrator first, calls the relevant store method, appends an audit row, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material.
The supported subcommands match `ApiKeyAdminCommandKind` exactly:
| Subcommand | Required options | Behaviour |
|------------|------------------|-----------|
| `init-db` | none | Runs the migrator and records an audit entry. |
| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash, and prints the assembled `mxgw_<keyId>_<secret>` token. |
| `list-keys` | none | Lists every stored key with its scopes and revocation state. |
| `revoke-key` | `--key-id` | Sets `revoked_utc` if the key is currently active. |
| `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. |
Examples:
```bash
mxgateway apikey init-db
mxgateway apikey create-key --key-id ops.alice --display-name "Alice (ops)" --scopes read,write
mxgateway apikey list-keys --json
mxgateway apikey revoke-key --key-id ops.alice
mxgateway apikey rotate-key --key-id ops.alice
```
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling.
## Scope Serialization
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
```csharp
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
```
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
## Registration
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` wires every service in this subsystem as a singleton and registers the migration hosted service:
```csharp
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
services.AddSingleton<ApiKeyAdminCliRunner>();
services.AddSingleton<AuthSqliteConnectionFactory>();
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
services.AddHostedService<AuthStoreMigrationHostedService>();
return services;
}
```
Singletons are safe because each operation opens its own short-lived `SqliteConnection` through the factory; there is no shared mutable state inside the services.
## Related Documentation
- [Gateway Configuration](./GatewayConfiguration.md)
- [Authorization](./Authorization.md)
- [Diagnostics](./Diagnostics.md)
+214
View File
@@ -0,0 +1,214 @@
# Gateway gRPC Authorization
The authorization subsystem enforces per-RPC scope checks against the authenticated `ApiKeyIdentity` produced by the authentication layer, so service implementations never need to repeat permission logic.
## 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.
The participating types live under `src/MxGateway.Server/Security/Authorization/`:
- `GatewayGrpcAuthorizationInterceptor` runs the authenticate-then-authorize pipeline for unary and server-streaming calls.
- `GatewayGrpcScopeResolver` maps a request message (and, for `MxCommandRequest`, the inner `MxCommandKind`) to the scope string that must be present on the caller.
- `GatewayScopes` exposes the canonical scope constants used by the resolver and any downstream consumer.
- `GatewayRequestIdentityAccessor` and `IGatewayRequestIdentityAccessor` expose the verified identity to handlers and any service code that runs inside the call.
- `GrpcAuthorizationServiceCollectionExtensions` wires the components into the DI container and the gRPC pipeline.
The `ApiKeyIdentity` consumed here is produced by the authentication layer; see [Authentication](./Authentication.md) for how it is built and how scopes are persisted.
## Why an Interceptor
Centralizing the policy in `GatewayGrpcAuthorizationInterceptor` produces three concrete benefits:
1. Every RPC defined in `MxAccessGatewayService` is covered by construction. A new RPC inherits the check the moment its request type is added to `GatewayGrpcScopeResolver`, instead of relying on each service method to remember to call an authorization helper.
2. The service class stays a thin translator between proto contracts and domain calls. RPC methods do not branch on identity or scope, which keeps the AGENTS.md guideline that gRPC handlers contain no policy.
3. Authentication and authorization happen in one place, so the gRPC `Status` mapping is consistent. A failed key check always returns `Unauthenticated`, and a missing scope always returns `PermissionDenied` with the offending scope name.
## Interceptor Flow
`GatewayGrpcAuthorizationInterceptor` overrides both `UnaryServerHandler` and `ServerStreamingServerHandler`. Both call the same private `AuthenticateAndAuthorizeAsync` helper before invoking the continuation, then push the resolved identity onto the accessor for the duration of the call.
```csharp
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
ApiKeyIdentity? identity = await AuthenticateAndAuthorizeAsync(request, context).ConfigureAwait(false);
IDisposable? identityScope = identity is null ? null : identityAccessor.Push(identity);
using (identityScope)
{
return await continuation(request, context).ConfigureAwait(false);
}
}
```
The shared helper performs the actual decision:
```csharp
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return null;
}
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(authorizationHeader, context.CancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
{
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
string requiredScope = scopeResolver.ResolveRequiredScope(request);
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
StatusCode.PermissionDenied,
$"API key is missing required scope '{requiredScope}'."));
}
return verificationResult.Identity;
```
The flow is:
1. If `GatewayOptions.Authentication.Mode` is `AuthenticationMode.Disabled`, the helper returns `null` immediately. No identity is pushed onto the accessor and the continuation runs without scope enforcement. This matches the `AuthenticationMode` enum, which only defines `ApiKey` and `Disabled`.
2. Otherwise, the `authorization` request header is read directly off `ServerCallContext.RequestHeaders` and handed to `IApiKeyVerifier.VerifyAsync`. A failed verification or a missing identity throws `RpcException` with `StatusCode.Unauthenticated`.
3. `GatewayGrpcScopeResolver.ResolveRequiredScope(request)` produces the scope string. If the identity's `Scopes` set does not contain it, the helper throws `RpcException` with `StatusCode.PermissionDenied` and embeds the missing scope name in `Status.Detail` so callers can diagnose the failure.
4. On success, the verified `ApiKeyIdentity` is returned and pushed onto `IGatewayRequestIdentityAccessor` for the lifetime of the call.
The status codes are deliberately distinct: `Unauthenticated` signals "we do not know who you are," and `PermissionDenied` signals "we know who you are, but you cannot do this." Treating the two as the same code would make troubleshooting harder for client implementations.
## Scope Resolution
`GatewayGrpcScopeResolver` is a stateless singleton that switches on the runtime request type. Top-level RPC requests map directly:
```csharp
public string ResolveRequiredScope(object request)
{
return request switch
{
OpenSessionRequest => GatewayScopes.SessionOpen,
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
_ => GatewayScopes.Admin
};
}
```
The `_ => GatewayScopes.Admin` fallback is intentional: any future request type that the resolver does not recognize fails closed, requiring the strongest scope until the resolver is updated.
`MxCommandRequest` is special because it multiplexes many MxAccess operations through a single RPC. The resolver inspects the embedded `MxCommandKind` so each operation gets its own scope:
```csharp
private static string ResolveCommandScope(MxCommandKind kind)
{
return kind switch
{
MxCommandKind.Write or
MxCommandKind.Write2 => GatewayScopes.InvokeWrite,
MxCommandKind.WriteSecured or
MxCommandKind.WriteSecured2 or
MxCommandKind.AuthenticateUser => GatewayScopes.InvokeSecure,
MxCommandKind.ArchestraUserToId or
MxCommandKind.GetSessionState or
MxCommandKind.GetWorkerInfo => GatewayScopes.MetadataRead,
MxCommandKind.DrainEvents => GatewayScopes.EventsRead,
MxCommandKind.ShutdownWorker => GatewayScopes.Admin,
_ => GatewayScopes.InvokeRead
};
}
```
Reads (`Register`, `AddItem`, `Advise`, and any other unspecified kind) fall through to `InvokeRead`, which keeps the matrix small while still separating reads from writes, secured writes, metadata lookups, event drains, and worker shutdown.
## Scope Catalog
`GatewayScopes` is the single source of truth for scope strings. Every entry is currently mapped by either the resolver or another security component:
| Constant | Value | Required For |
|----------|-------|--------------|
| `SessionOpen` | `session:open` | `OpenSessionRequest` |
| `SessionClose` | `session:close` | `CloseSessionRequest` |
| `EventsRead` | `events:read` | `StreamEventsRequest`, `MxCommandKind.DrainEvents` |
| `InvokeRead` | `invoke:read` | `MxCommandRequest` for read-style command kinds (`Register`, `AddItem`, `Advise`, and any kind not otherwise mapped) |
| `InvokeWrite` | `invoke:write` | `MxCommandKind.Write`, `MxCommandKind.Write2` |
| `InvokeSecure` | `invoke:secure` | `MxCommandKind.WriteSecured`, `MxCommandKind.WriteSecured2`, `MxCommandKind.AuthenticateUser` |
| `MetadataRead` | `metadata:read` | `MxCommandKind.ArchestraUserToId`, `MxCommandKind.GetSessionState`, `MxCommandKind.GetWorkerInfo`, `GalaxyRepository.TestConnection`, `GalaxyRepository.GetLastDeployTime`, `GalaxyRepository.DiscoverHierarchy`, `GalaxyRepository.WatchDeployEvents` |
| `Admin` | `admin` | `MxCommandKind.ShutdownWorker`, the default for any unrecognized request type, and the dashboard authorization policy |
The `Admin` constant is also referenced by `DashboardAuthenticator` and `DashboardAuthorizationHandler` so that the dashboard and the gRPC layer agree on what "admin" means.
## Identity Access for Downstream Layers
Once authorization passes, `GatewayGrpcAuthorizationInterceptor` calls `identityAccessor.Push(identity)` and disposes the returned scope when the continuation completes. `GatewayRequestIdentityAccessor` stores the active identity in an `AsyncLocal<ApiKeyIdentity?>`, so the value flows across `await` boundaries and child tasks belonging to the same request.
```csharp
public sealed class GatewayRequestIdentityAccessor : IGatewayRequestIdentityAccessor
{
private readonly AsyncLocal<ApiKeyIdentity?> currentIdentity = new();
public ApiKeyIdentity? Current => currentIdentity.Value;
public IDisposable Push(ApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
ApiKeyIdentity? previousIdentity = currentIdentity.Value;
currentIdentity.Value = identity;
return new IdentityScope(this, previousIdentity);
}
}
```
The returned `IdentityScope` restores the previous value on dispose rather than clearing it. This makes the accessor safe for nested pushes, even though the current interceptor only pushes once per call. Disposing twice is a no-op because of the `disposed` guard inside `IdentityScope`.
Downstream code consumes the accessor through the `IGatewayRequestIdentityAccessor` interface:
```csharp
public interface IGatewayRequestIdentityAccessor
{
ApiKeyIdentity? Current { get; }
IDisposable Push(ApiKeyIdentity identity);
}
```
`MxAccessGatewayService` takes `IGatewayRequestIdentityAccessor` as a constructor dependency and reads `Current` whenever it needs to attach the calling identity to a domain operation, which keeps the service free of header parsing or scope checks.
When `AuthenticationMode.Disabled` is configured, no identity is pushed, so `Current` returns `null`. Downstream code must tolerate that, just as it tolerates the absence of a scope check.
## Registration
`GrpcAuthorizationServiceCollectionExtensions.AddGatewayGrpcAuthorization` is the single entry point that registers every component and inserts the interceptor into the gRPC pipeline:
```csharp
public static IServiceCollection AddGatewayGrpcAuthorization(this IServiceCollection services)
{
services.AddSingleton<GatewayGrpcScopeResolver>();
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
return services;
}
```
Singleton lifetimes are appropriate because none of the three classes hold per-request state on instance fields; the request-scoped value lives inside the `AsyncLocal` on `GatewayRequestIdentityAccessor`. `GatewayApplication` calls `builder.Services.AddGatewayGrpcAuthorization()` during startup, and the call also performs `AddGrpc`, so the gateway never registers gRPC without the interceptor attached.
## Related Documentation
- [Authentication](./Authentication.md)
- [Grpc](./Grpc.md)
- [GatewayConfiguration](./GatewayConfiguration.md)
- [Galaxy Repository Browse](./GalaxyRepository.md)
+7
View File
@@ -28,6 +28,13 @@ worker IPC envelope and control messages. It imports
`mxaccess_gateway.proto` so the worker and gateway use the same command, reply,
event, value, and status shapes.
`src/MxGateway.Contracts/Protos/galaxy_repository.proto` defines the
`GalaxyRepository` service used by clients to browse the Galaxy Repository
(deployed object hierarchy and dynamic attributes). The service is metadata-
only and does not share types with `mxaccess_gateway.proto`. See
[Galaxy Repository Browse](./GalaxyRepository.md) for the RPC catalog and
behavior.
Generated C# output is written to `src/MxGateway.Contracts/Generated/`. Do not
hand-edit generated files.
+222
View File
@@ -0,0 +1,222 @@
# Gateway Diagnostics
The diagnostics subsystem provides structured logging, credential redaction, and request-scoped log enrichment for the gateway. It lives under `src/MxGateway.Server/Diagnostics/` and is wired into the ASP.NET Core pipeline so every gRPC and HTTP request carries the same correlation fields.
## 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.
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.
## Log Scopes
`GatewayLogScope` is a record that captures the fields attached to a logger scope. It only emits keys whose values are non-null, so callers can supply just the identifiers they know about:
```csharp
public sealed record GatewayLogScope(
string? SessionId = null,
int? WorkerProcessId = null,
ulong? CorrelationId = null,
string? CommandMethod = null,
string? ClientIdentity = null)
{
public IReadOnlyDictionary<string, object?> ToDictionary()
{
Dictionary<string, object?> values = [];
AddIfPresent(values, "SessionId", SessionId);
AddIfPresent(values, "WorkerProcessId", WorkerProcessId);
AddIfPresent(values, "CorrelationId", CorrelationId);
AddIfPresent(values, "CommandMethod", CommandMethod);
AddIfPresent(values, "ClientIdentity", GatewayLogRedactor.RedactClientIdentity(ClientIdentity));
return values;
}
```
`ClientIdentity` is passed through `GatewayLogRedactor.RedactClientIdentity` inside `ToDictionary` rather than at the call site. This guarantees that any logger scope built from a `GatewayLogScope` cannot accidentally surface a raw API key, even when a caller forgets to redact before constructing the scope.
### How scopes are pushed
`GatewayLoggerExtensions` exposes a single method that converts a `GatewayLogScope` into the dictionary form expected by `ILogger.BeginScope`:
```csharp
public static class GatewayLoggerExtensions
{
public static IDisposable? BeginGatewayScope(
this ILogger logger,
GatewayLogScope scope)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(scope);
return logger.BeginScope(scope.ToDictionary());
}
}
```
The returned `IDisposable?` follows the standard `BeginScope` contract: callers wrap it in a `using` to bound the scope to a request, command, or worker interaction.
## Redaction Rules
`GatewayLogRedactor` centralizes every redaction decision so that policy changes live in one file. Three categories of input are handled differently because each has different "safe to log" prefixes.
### Sensitive command methods
A static set names the MXAccess commands that are known to carry credentials in their payloads:
```csharp
private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase)
{
"AuthenticateUser",
"WriteSecured",
"WriteSecured2"
};
public static bool IsCredentialBearingCommand(string? commandMethod)
{
return commandMethod is not null
&& SensitiveCommandMethods.Contains(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.
### API key redaction
`RedactApiKey` is built around the `mxgw_` API key format issued by the gateway. It preserves the bearer scheme and the key id segment so that operators can correlate a log entry to a specific principal, but always strips the secret tail:
```csharp
public static string? RedactApiKey(string? authorizationHeader)
{
if (string.IsNullOrWhiteSpace(authorizationHeader))
{
return authorizationHeader;
}
const string bearerPrefix = "Bearer ";
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return RedactedValue;
}
string token = authorizationHeader[bearerPrefix.Length..].Trim();
if (!token.StartsWith("mxgw_", StringComparison.OrdinalIgnoreCase))
{
return $"{bearerPrefix}{RedactedValue}";
}
string[] tokenParts = token.Split('_', 3, StringSplitOptions.RemoveEmptyEntries);
if (tokenParts.Length < 2)
{
return $"{bearerPrefix}mxgw_{RedactedValue}";
}
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
}
```
The split uses `count: 3` because the secret portion may itself contain underscores; only the first two segments (`mxgw` and the key id) are kept verbatim. Authorization headers that are not bearer tokens are reduced to `[redacted]` rather than passed through, since the gateway cannot reason about their structure.
`RedactClientIdentity` is the entry point used by `GatewayLogScope` and `DashboardRedactor`. It only invokes `RedactApiKey` when the input contains the `mxgw_` marker, leaving non-key identities (for example, Windows account names) untouched.
### Command value redaction
`RedactCommandValue` enforces the "values are opt-in and redacted by default" rule:
```csharp
public static object? RedactCommandValue(
string? commandMethod,
object? value,
bool valueLoggingEnabled = false)
{
if (value is null)
{
return null;
}
if (!valueLoggingEnabled || IsCredentialBearingCommand(commandMethod))
{
return RedactedValue;
}
return value;
}
```
Two rules combine here. First, when `valueLoggingEnabled` is `false` (the default), every value is replaced with `[redacted]`. Second, even when value logging is enabled, credential-bearing commands still redact. The credential check is therefore unconditional and cannot be overridden by configuration.
The shared `RedactedValue` constant is `"[redacted]"`. `DashboardRedactor` reuses it so that gateway logs and dashboard renders use the same placeholder.
## Request Logging Middleware
`GatewayRequestLoggingMiddlewareExtensions.UseGatewayRequestLoggingScope` registers the middleware that pushes a `GatewayLogScope` for the duration of every request:
```csharp
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
{
ArgumentNullException.ThrowIfNull(app);
return app.Use(async (context, next) =>
{
ILogger logger = context.RequestServices
.GetRequiredService<ILoggerFactory>()
.CreateLogger("MxGateway.Request");
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
SessionId: ReadHeader(context, SessionIdHeaderName),
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName),
CommandMethod: ReadHeader(context, CommandMethodHeaderName),
ClientIdentity: ReadHeader(context, "authorization")));
await next(context);
});
}
```
The scope is keyed off four custom headers and the standard `authorization` header:
| Header | Scope field | Type |
|--------|-------------|------|
| `x-session-id` | `SessionId` | string |
| `x-worker-process-id` | `WorkerProcessId` | int |
| `x-correlation-id` | `CorrelationId` | ulong |
| `x-command-method` | `CommandMethod` | string |
| `authorization` | `ClientIdentity` | string (redacted) |
The numeric headers use `int.TryParse` and `ulong.TryParse`; missing or unparseable values become `null` and are dropped by `GatewayLogScope.ToDictionary`. This keeps the middleware tolerant of clients that do not yet emit every header, which matters because the earliest call in a session (`OpenSession`) has no `SessionId` to send.
The logger category is `MxGateway.Request`, which lets operators filter the request scope events independently from per-component categories.
### Pipeline ordering
`GatewayApplication.Build` registers the middleware before authentication, authorization, and endpoint mapping:
```csharp
app.UseGatewayRequestLoggingScope();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapGatewayEndpoints();
```
The order matters: putting the logging scope first ensures that authentication failures, authorization denials, and endpoint exceptions all run inside the request scope, so failure logs still carry the correlation id and session id headers that the caller sent. The `ClientIdentity` field is redacted before logging, so reading the `authorization` header at this stage does not leak the bearer secret into authentication failure logs.
## Consumers
`GatewayLoggerExtensions.BeginGatewayScope` is consumed by `GatewayRequestLoggingMiddlewareExtensions` to attach the per-request scope. Component-level call sites build narrower `GatewayLogScope` instances (for example, with a known `WorkerProcessId` after a worker launch) and push a nested scope on top of the request scope.
`GatewayLogRedactor` is consumed in three places:
- `GatewayLogScope.ToDictionary` redacts `ClientIdentity` whenever a scope is materialized.
- `DashboardRedactor.Redact` delegates to `RedactClientIdentity` for any value containing the `mxgw_` marker, then falls back to a marker-keyword check for fields like `password` or `token`. This keeps dashboard renders aligned with log redaction.
- `MxGateway.Tests/Diagnostics/GatewayLogRedactorTests.cs` covers each redaction branch, including the assertion that `WriteSecured` values stay redacted even when `valueLoggingEnabled` is true.
## Related Documentation
- [Sessions](./Sessions.md)
- [gRPC](./Grpc.md)
- [Authentication](./Authentication.md)
+271
View File
@@ -0,0 +1,271 @@
# Galaxy Repository Browse
The gateway exposes a read-only browse surface over the AVEVA System Platform
Galaxy Repository (the SQL Server database named `ZB`). Clients use it to
enumerate the deployed object hierarchy and each object's dynamic attributes
before subscribing to runtime values via the existing `MxAccessGateway` RPCs.
This is a metadata layer: it never reads or writes runtime tag values, never
goes through MXAccess COM, and never needs the x86 worker. It runs entirely
inside the .NET 10 gateway process and talks to the Galaxy Repository over
plain SQL.
## Why It Exists
Without browse, a client must already know `tag_name.AttributeName` strings to
issue `AddItem`. The Galaxy Repository is the authoritative source for those
names — the same database that System Platform itself reads when the
ArchestrA IDE renders the deployment tree. Surfacing that data over gRPC lets
remote clients build a navigable address space without any coupling to the
COM layer or the host platform.
The query bodies are kept byte-for-byte identical to the equivalent OPC UA
server in the OtOpcUa project so the two consumers see the same row sets.
## RPC Surface
The service is defined in
`src/MxGateway.Contracts/Protos/galaxy_repository.proto` under package
`galaxy_repository.v1`.
| RPC | Purpose |
|-----|---------|
| `TestConnection` | Connectivity probe. Returns `{ ok: bool }` after a `SELECT 1`. Does not throw on SQL failure — returns `ok = false`. Always hits SQL directly so it remains a true health check. |
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
| `DiscoverHierarchy` | Returns the full deployed hierarchy plus every object's dynamic attributes. **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
`DiscoverHierarchy` is intentionally a single unary RPC rather than a stream:
the row set is small (thousands of objects, low tens-of-thousands of
attributes for typical Galaxies) and clients almost always want the whole tree
at once.
## Hierarchy Cache
The gateway holds a single shared `IGalaxyHierarchyCache`
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) — every
`DiscoverHierarchy` and `GetLastDeployTime` request reads from this cache
rather than hitting SQL. Many clients can browse concurrently with at most
one SQL query in flight.
Refresh strategy is **deploy-time gated**:
1. The hosted `GalaxyHierarchyRefreshService` ticks every
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` seconds (default 30).
2. Each tick queries the cheap `SELECT time_of_last_deploy FROM galaxy` first.
3. If the deploy timestamp is unchanged, the heavy hierarchy + attributes
queries are **skipped**. The cache simply marks `LastSuccessAt`.
4. If the deploy timestamp changed (or no data has loaded yet), the cache
pulls hierarchy + attributes, materializes a `DiscoverHierarchyReply`
once, replaces the entry atomically, and publishes a deploy event.
Materializing the reply at refresh time means subsequent `DiscoverHierarchy`
calls return a pre-built proto message — no per-request projection, no
per-request allocations beyond the gRPC serializer's frame.
When SQL is unreachable, the cache retains the previous data and flips
`Status` to `Stale` (or `Unavailable` if no data was ever loaded). A
`SqlException` never bubbles out as the client-facing error.
### First-load behavior
If a client calls `DiscoverHierarchy` before the background service has
populated the cache, the gRPC handler waits up to 5 seconds for the first
load to complete before returning. If the first load fails or times out,
the client gets `Unavailable` with a short reason. Once any load completes
(success or failure), this wait is skipped on subsequent calls.
## Deploy Notifications
`WatchDeployEvents` is a server-streaming RPC backed by
`IGalaxyDeployNotifier` (`src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`).
The notifier maintains a private bounded channel per subscriber so a slow
client cannot back-pressure other subscribers or the publisher.
Subscriber lifecycle:
1. On subscribe, the notifier emits the **current** event (the state of the
most recent successful refresh) so the subscriber can sync its local cache
without waiting for the next deploy. Clients that already know about that
deploy can pass `last_seen_deploy_time` in the request to suppress the
bootstrap event.
2. As the cache observes new deploy timestamps, it publishes one event per
change. Each event carries:
- `sequence` — monotonic per server start; gaps signal a dropped event.
- `observed_at` — server wall-clock when the cache saw the deploy.
- `time_of_last_deploy` (+ `time_of_last_deploy_present`) — the Galaxy
timestamp; absent only when the source row reports null.
- `object_count`, `attribute_count` — counts on the new deploy, useful for
dashboards and "did anything important change" gates without re-pulling.
3. If the subscriber's per-subscriber buffer fills (bound = 16 events with
`DropOldest`), older events are dropped. Clients use the `sequence` field
to detect this.
4. Cancellation (or transport disconnect) removes the subscriber.
Typical client pattern:
```text
1. Open WatchDeployEvents stream (with last_seen_deploy_time if you have one).
2. On each event, decide whether to call DiscoverHierarchy to refresh local cache.
3. If sequence skipped a number, treat it as a dropped event and refresh.
```
### Reply Shape
```proto
message GalaxyObject {
int32 gobject_id = 1;
string tag_name = 2;
string contained_name = 3;
string browse_name = 4; // contained_name when present, else tag_name
int32 parent_gobject_id = 5;
bool is_area = 6;
int32 category_id = 7;
int32 hosted_by_gobject_id = 8;
repeated string template_chain = 9;
repeated GalaxyAttribute attributes = 10;
}
message GalaxyAttribute {
string attribute_name = 1;
string full_tag_reference = 2; // e.g. "DelmiaReceiver_001.DownloadPath"
int32 mx_data_type = 3; // raw Galaxy mx_data_type integer
string data_type_name = 4;
bool is_array = 5;
int32 array_dimension = 6;
bool array_dimension_present = 7; // distinguishes "no dimension" from 0
int32 mx_attribute_category = 8;
int32 security_classification = 9;
bool is_historized = 10;
bool is_alarm = 11;
}
```
### Contained Name vs Tag Name
Galaxy objects carry two names. `tag_name` is globally unique and is what
MXAccess expects in `AddItem`. `contained_name` is the human-readable name
used in the IDE browse tree, scoped to the parent. The browse RPC exposes
both: clients display `browse_name` to users and pass `tag_name` (or
`full_tag_reference`) into MXAccess subscriptions. When `contained_name` is
empty (top-level objects), `browse_name` falls back to `tag_name`.
### Data Types
`mx_data_type` is returned as the raw Galaxy integer rather than mapped to a
language-neutral enum. The gateway makes no assumption about the client's
target type system — clients map to OPC UA, JSON, .NET CLR types, or
something else as appropriate. The Galaxy `data_type` table description is
also passed through as `data_type_name`.
`array_dimension_present` is a separate boolean because protobuf scalar
fields cannot express null. Use it to distinguish "no dimension reported" from
"dimension is zero."
## Architecture
```text
gRPC client(s)
-> GalaxyRepositoryGrpcService (src/MxGateway.Server/Grpc/)
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current
WatchDeployEvents -> IGalaxyDeployNotifier
TestConnection -> GalaxyRepository (direct SQL)
GalaxyHierarchyRefreshService (BackgroundService)
-> IGalaxyHierarchyCache.RefreshAsync
-> GalaxyRepository.GetLastDeployTimeAsync (cheap, every tick)
-> GalaxyRepository.GetHierarchyAsync (only on deploy change)
-> GalaxyRepository.GetAttributesAsync (only on deploy change)
-> GalaxyProtoMapper.MapObject (materialize DiscoverHierarchyReply once)
-> IGalaxyDeployNotifier.Publish (only on deploy change)
```
Component breakdown:
- `GalaxyRepository` (`src/MxGateway.Server/Galaxy/GalaxyRepository.cs`) holds
the SQL. Its constants `HierarchySql` and `AttributesSql` are copied verbatim
from the OtOpcUa project; do not edit them in isolation here. The two
queries walk template-derivation and package-derivation chains via
recursive CTEs and pick the most-derived attribute override per object.
- `GalaxyHierarchyCache`
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyCache.cs`) holds the most
recent immutable `GalaxyHierarchyCacheEntry` (rows + materialized proto
reply + counts + status). All gRPC clients share the same entry.
- `GalaxyHierarchyRefreshService`
(`src/MxGateway.Server/Galaxy/GalaxyHierarchyRefreshService.cs`) is a
hosted `BackgroundService` that drives `RefreshAsync` on the configured
interval, with deploy-time gating to avoid unnecessary heavy queries.
- `GalaxyDeployNotifier`
(`src/MxGateway.Server/Galaxy/GalaxyDeployNotifier.cs`) is a thin
per-subscriber-channel fan-out for streaming clients.
- `GalaxyProtoMapper`
(`src/MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
proto messages. Used by the cache during refresh to materialize the reply
once.
- `GalaxyRepositoryGrpcService`
(`src/MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
the four RPCs.
## Configuration
Bound to `MxGateway:Galaxy` via `GalaxyRepositoryOptions`.
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository. Integrated Security against `localhost` is the dev default; production deployments should override this through the standard double-underscore environment variable form, e.g. `MxGateway__Galaxy__ConnectionString`. |
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout. Applies to all three RPCs. |
The connection string is not treated as a secret in dev (`Integrated
Security`), but production deployments that use SQL authentication should set
the override via environment variable rather than committing credentials to
`appsettings.json`.
## Authorization
All four Galaxy RPCs (including `WatchDeployEvents`) require the
`metadata:read` API-key scope. Browse is read-only metadata, equivalent in
privilege to `MxCommandKind.GetSessionState` or `MxCommandKind.GetWorkerInfo`.
The mapping lives in `GatewayGrpcScopeResolver`; see
[Authorization](./Authorization.md) for the full scope catalog.
A request without an API key returns `Unauthenticated`. A request with a key
that lacks `metadata:read` returns `PermissionDenied` with the missing scope
embedded in the status detail.
## Dashboard Surface
The gateway's Blazor dashboard surfaces a Galaxy summary in two places:
- An overview card on `/dashboard` showing connectivity status, last deploy
timestamp, object count (with area count), attribute total, historized and
alarm counts, and last successful refresh.
- A dedicated `/dashboard/galaxy` page with object-category and top-template
breakdowns plus a Sync Info table covering last successful refresh, last
attempt, refresh interval, redacted connection string, and command timeout.
Both views are projected from the same `IGalaxyHierarchyCache` that backs the
gRPC service. The dashboard does not run its own refresh — when the
background `GalaxyHierarchyRefreshService` updates the cache, both the
overview card and the `/dashboard/galaxy` page pick up the new state on the
next dashboard tick. When SQL is unreachable, the cache retains the previous
data and flips `Status` to `Stale` or `Unavailable`; the dashboard surfaces
that as a yellow or red status badge plus the truncated error.
## Operational Notes
- The service is registered alongside `MxAccessGatewayService` in
`GatewayApplication.MapGatewayEndpoints`. Both services share the same
authorization interceptor and authentication policy.
- Failures to reach the Galaxy database surface as `Unavailable`. Detailed
SQL exceptions are logged at `Warning` and never returned to clients.
- Integration tests live in
`src/MxGateway.IntegrationTests/Galaxy/GalaxyRepositoryLiveTests.cs`. Set
`MXGATEWAY_RUN_LIVE_GALAXY_TESTS=1` (and optionally
`MXGATEWAY_LIVE_GALAXY_CONN`) to run them; otherwise they skip.
## Related Documentation
- [Contracts](./Contracts.md)
- [Grpc](./Grpc.md)
- [Authorization](./Authorization.md)
- [Gateway Configuration](./GatewayConfiguration.md)
+17
View File
@@ -53,6 +53,11 @@ paths, timeouts, queue sizes, enum values, or protocol values are invalid.
},
"Protocol": {
"WorkerProtocolVersion": 1
},
"Galaxy": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
"CommandTimeoutSeconds": 60,
"DashboardRefreshIntervalSeconds": 30
}
}
}
@@ -146,9 +151,21 @@ The protocol option is exposed for diagnostics and explicit deployment
configuration, not for compatibility negotiation. A mismatch fails validation
at startup.
## Galaxy Options
| Option | Default | Description |
|--------|---------|-------------|
| `MxGateway:Galaxy:ConnectionString` | `Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;` | SQL Server connection string for the Galaxy Repository (`ZB`) used by the `GalaxyRepository` browse RPCs. Override in production via `MxGateway__Galaxy__ConnectionString`. |
| `MxGateway:Galaxy:CommandTimeoutSeconds` | `60` | Per-command SQL timeout for all Galaxy browse RPCs. |
| `MxGateway:Galaxy:DashboardRefreshIntervalSeconds` | `30` | Interval between background refreshes of the dashboard Galaxy summary cache. SQL is hit at most once per interval regardless of dashboard render rate. |
See [Galaxy Repository Browse](./GalaxyRepository.md) for the RPC surface and
behavior.
## Related Documentation
- [Gateway Process Detailed Design](./gateway-process-design.md)
- [Gateway Dashboard Detailed Design](./gateway-dashboard-design.md)
- [Worker Process Launcher](./WorkerProcessLauncher.md)
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
- [Galaxy Repository Browse](./GalaxyRepository.md)
+236
View File
@@ -0,0 +1,236 @@
# Gateway gRPC Service Layer
The gRPC service layer is the public entry point for client traffic. It is intentionally thin: handlers validate the incoming request, look up or open a session, dispatch to the worker through the session manager, and translate worker replies and events back into public proto types.
## 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 layer is composed of four collaborators:
| Type | Lifetime | Role |
|------|----------|------|
| `MxAccessGatewayService` | scoped (gRPC) | Implements the four `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
Registration happens in `GatewayApplication`:
```csharp
builder.Services.AddSingleton<MxAccessGrpcMapper>();
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
```
The service itself is mapped as a normal gRPC endpoint via `endpoints.MapGrpcService<MxAccessGatewayService>()`.
A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It exposes the read-only Galaxy Repository browse surface and is documented separately in [Galaxy Repository Browse](./GalaxyRepository.md). It shares the authorization interceptor and authentication policy used by `MxAccessGatewayService`, but it does not go through the session manager or worker — it talks to SQL Server directly.
## RPC Handlers
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
### `OpenSession`
`OpenSession` validates the request, asks `ISessionManager` to open a session under the caller's identity, and returns a reply that advertises both protocol versions and the capabilities the gateway supports. Capability strings are static because the gateway has a fixed feature set per build; clients use them as a forward-compatibility hint rather than runtime negotiation.
```csharp
GatewaySession session = await sessionManager
.OpenSessionAsync(
SessionOpenRequest.FromContract(request),
ResolveClientIdentity(),
context.CancellationToken)
.ConfigureAwait(false);
OpenSessionReply reply = new()
{
SessionId = session.SessionId,
BackendName = session.BackendName,
WorkerProcessId = session.WorkerProcessId ?? 0,
WorkerProtocolVersion = GatewayContractInfo.WorkerProtocolVersion,
GatewayProtocolVersion = GatewayContractInfo.GatewayProtocolVersion,
DefaultCommandTimeout = Google.Protobuf.WellKnownTypes.Duration.FromTimeSpan(session.CommandTimeout),
ProtocolStatus = MxAccessGrpcMapper.Ok(),
};
```
`ResolveClientIdentity()` reads `IGatewayRequestIdentityAccessor.Current` and prefers `DisplayName`, falling back to `KeyId`. The accessor is populated by the authorization interceptor before the handler runs (see [Authorization](./Authorization.md)).
### `CloseSession`
The handler delegates to `sessionManager.CloseSessionAsync` and converts the resulting `SessionCloseResult` into a `CloseSessionReply`. The handler distinguishes the two outcomes via the protocol status message — `"Session was already closed."` versus `"Session closed."` — so callers do not need to reason about idempotency from a status code alone.
### `Invoke`
`Invoke` is the unary command path. It runs the validator (which enforces payload-vs-kind matching), uses the mapper to wrap the public `MxCommand` in a `WorkerCommand` with an enqueue timestamp, calls `sessionManager.InvokeAsync`, and unwraps the worker reply.
```csharp
requestValidator.ValidateInvoke(request);
WorkerCommand workerCommand = mapper.MapCommand(request);
WorkerCommandReply workerReply = await sessionManager
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
.ConfigureAwait(false);
return mapper.MapCommandReply(workerReply);
```
Carrying the enqueue timestamp into the worker layer is what lets queue-wait time be measured separately from worker-side execution time when troubleshooting timeouts.
### `StreamEvents`
`StreamEvents` is a server-streaming RPC. The handler delegates the full pipeline to `IEventStreamService` and just forwards each `MxEvent` onto the response stream. Keeping the channel and producer/consumer machinery out of the handler means cancellation, exception mapping, and metric bookkeeping live in one place.
## Validation Rules
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
| RPC | Rule | Status |
|-----|------|--------|
| `OpenSession` | `command_timeout`, when set, must be `> 0`. | `InvalidArgument` |
| `CloseSession` | `session_id` must be non-empty. | `InvalidArgument` |
| `StreamEvents` | `session_id` must be non-empty. | `InvalidArgument` |
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
```csharp
private static void ValidateCommandPayload(MxCommand command)
{
MxCommand.PayloadOneofCase expectedPayload = ExpectedPayload(command.Kind);
if (command.PayloadCase != expectedPayload)
{
throw InvalidArgument(
$"Command kind {command.Kind} requires payload {expectedPayload} but received {command.PayloadCase}.");
}
}
```
`ExpectedPayload` enumerates every `MxCommandKind` that has a matching payload case; unknown kinds map to `PayloadOneofCase.None`, which forces a mismatch and therefore a rejection.
## Mapping Rules
`MxAccessGrpcMapper` is the only place that translates between public proto types and internal worker types. Two design choices are worth calling out.
The mapper clones the inbound `MxCommand` rather than reusing the reference. This isolates the worker pipeline from any later mutation of the request graph by the gRPC framework or interceptors:
```csharp
public WorkerCommand MapCommand(MxCommandRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.Command);
return new WorkerCommand
{
Command = request.Command.Clone(),
EnqueueTimestamp = Timestamp.FromDateTimeOffset(_timeProvider.GetUtcNow()),
};
}
```
When the worker reply or event payload is missing, the mapper returns a synthetic public message with `ProtocolStatusCode.ProtocolViolation` (for replies) or a sentinel `MxEvent` with `MxEventFamily.Unspecified` (for events). The gateway never relays a partial frame to clients — anything missing is reported as a protocol violation against the worker, not a transport error against the client.
The mapper also exposes static factory methods for every `ProtocolStatusCode` (`Ok`, `InvalidRequest`, `SessionNotFound`, `SessionNotReady`, `WorkerUnavailable`, `Timeout`, `Canceled`, `ProtocolViolation`) so that handlers and tests can produce status payloads without duplicating the enum-to-string mapping.
## Exception to Status Mapping
Every handler wraps its body in `try { ... } catch (Exception exception) when (exception is not RpcException) { throw MapException(exception); }`. `RpcException` is allowed to propagate untouched (the validator already produces them with the right code). All other exceptions are translated by `MapException`, which knows three categories:
- `OperationCanceledException` becomes `StatusCode.Cancelled`.
- `SessionManagerException` is mapped by its `ErrorCode`.
- `WorkerClientException` is mapped by its `ErrorCode`.
Anything else is logged at warning and surfaced as `Unavailable` with a generic message — clients see a retryable status, and the unexpected exception is captured in gateway logs rather than leaked over the wire.
```csharp
StatusCode statusCode = exception.ErrorCode switch
{
SessionManagerErrorCode.SessionNotFound => StatusCode.NotFound,
SessionManagerErrorCode.SessionNotReady => StatusCode.FailedPrecondition,
SessionManagerErrorCode.EventSubscriberAlreadyActive => StatusCode.ResourceExhausted,
SessionManagerErrorCode.EventQueueOverflow => StatusCode.ResourceExhausted,
SessionManagerErrorCode.SessionLimitExceeded => StatusCode.ResourceExhausted,
SessionManagerErrorCode.OpenFailed => StatusCode.Unavailable,
SessionManagerErrorCode.CloseFailed => StatusCode.Unavailable,
_ => StatusCode.Unavailable,
};
```
`WorkerClientException` follows the same pattern: `CommandTimeout` becomes `DeadlineExceeded`, `GatewayShutdown` becomes `Cancelled`, `InvalidState` becomes `FailedPrecondition`, `ProtocolViolation` becomes `Internal`, and unmapped codes fall through to `Unavailable`.
## Event Streaming Model
`EventStreamService` implements the `StreamEvents` pipeline. It exists as a separate service (rather than living inside `MxAccessGatewayService`) so that the channel, backpressure policy, and metric bookkeeping can be unit-tested without spinning up a Kestrel host.
The pipeline has three stages:
1. The session is resolved via `sessionManager.TryGetSession`. A miss raises `SessionManagerException(SessionNotFound)` so the handler reports `StatusCode.NotFound`.
2. `session.AttachEventSubscriber(...)` enforces the single-subscriber-per-session rule (or allows multiple subscribers if `Sessions:AllowMultipleEventSubscribers` is enabled). The returned `IDisposable` is released in the `finally` block, ensuring the subscriber slot is freed even when the client cancels mid-stream.
3. A `Channel<MxEvent>` decouples the worker-side producer from the gRPC writer. The channel is bounded by `Events:QueueCapacity` and configured for a single reader and writer:
```csharp
Channel<MxEvent> eventQueue = Channel.CreateBounded<MxEvent>(
new BoundedChannelOptions(options.Value.Events.QueueCapacity)
{
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.Wait,
AllowSynchronousContinuations = false,
});
```
The producer reads `WorkerEvent`s from the session, maps them, and writes to the channel. Per-session ordering is preserved end-to-end: events arrive from the worker in `WorkerSequence` order, the producer is the single writer, and the consumer drains FIFO. The producer also honours the request's `AfterWorkerSequence` cursor by skipping any event whose `WorkerSequence` is at or before the requested cutoff, which lets clients resume after a disconnect without server-side replay state.
### Backpressure
When `TryWrite` fails the queue is full. The handling depends on `Events:BackpressurePolicy`:
```csharp
if (!writer.TryWrite(publicEvent))
{
string message = $"Session {session.SessionId} event stream queue overflowed.";
metrics.QueueOverflow("grpc-event-stream");
if (options.Value.Events.BackpressurePolicy == EventBackpressurePolicy.FailFast)
{
session.MarkFaulted(message);
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
}
else
{
logger.LogDebug(
"Disconnecting event stream for session {SessionId} after queue overflow.",
session.SessionId);
}
writer.TryComplete(new SessionManagerException(
SessionManagerErrorCode.EventQueueOverflow,
message));
return;
}
```
Under `FailFast` the session is faulted so subsequent commands return `FailedPrecondition`; the client must reopen. Under the default policy only the stream is dropped and the session continues to accept commands, leaving recovery to the client (typically a fresh `StreamEvents` call with an updated `AfterWorkerSequence`). Either way, the consumer side observes `StatusCode.ResourceExhausted` via the `EventQueueOverflow` mapping above.
### Cancellation and Cleanup
The handler creates a linked cancellation token (`streamCts`) so that completing the consumer (client disconnect, error, or graceful end-of-stream) also cancels the producer. The `finally` block cancels the source, disposes the subscriber slot, awaits the producer (swallowing the expected cancellation), and emits `StreamDisconnected("Detached")` so dashboards see the disconnection regardless of cause.
`WorkerClientException` thrown by the producer marks the session as faulted before completing the channel — the worker is presumed gone, and any subsequent command on that session must observe the fault rather than silently retry.
## Authorization Interceptor Integration
Authorization is applied as a gRPC interceptor, registered in `GrpcAuthorizationServiceCollectionExtensions`:
```csharp
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
```
Because the interceptor runs before any handler, `MxAccessGatewayService` can safely assume the call has been authorized and that `IGatewayRequestIdentityAccessor.Current` is populated. The handler's only responsibility is to read the identity for `OpenSession` so the session is owned by the authenticated principal; it does not perform any authorization checks of its own. See [Authorization](./Authorization.md) for the policy and identity model.
## Related Documentation
- [Contracts](./Contracts.md)
- [Sessions](./Sessions.md)
- [Authorization](./Authorization.md)
- [Gateway Process Design](./gateway-process-design.md)
+210
View File
@@ -0,0 +1,210 @@
# Gateway Metrics
The metrics subsystem exposes counters, histograms, and observable gauges that describe gateway throughput, queue health, and worker lifecycle. Both the `System.Diagnostics.Metrics` pipeline and the in-memory `GatewayMetricsSnapshot` consume the same underlying state, so external collectors and the dashboard see consistent numbers.
## Overview
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
## Meter and OpenTelemetry Compatibility
The meter name is exposed as a constant so that hosting code can register it with an OpenTelemetry exporter:
```csharp
public sealed class GatewayMetrics : IDisposable
{
public const string MeterName = "MxGateway.Server";
public GatewayMetrics()
{
_meter = new Meter(MeterName, typeof(GatewayMetrics).Assembly.GetName().Version?.ToString());
_sessionsOpenedCounter = _meter.CreateCounter<long>("mxgateway.sessions.opened");
...
}
}
```
The meter version is the gateway assembly version, which gives exporters a stable identifier per build. All instrument names use the dotted `mxgateway.<area>.<event>` convention so they group cleanly under a single namespace in tools such as Prometheus, OTLP collectors, or `dotnet-counters`.
## Instrument Inventory
### Counters
All counters are `Counter<long>`. Tag values come from the call sites listed under [Recording Sites](#recording-sites).
| Instrument | Tags | What it measures |
|------------|------|------------------|
| `mxgateway.sessions.opened` | none | Successful `SessionManager.OpenSession` completions. |
| `mxgateway.sessions.closed` | none | Sessions closed cleanly via `SessionManager`. |
| `mxgateway.commands.started` | `method` | Command dispatches initiated by `WorkerClient`. |
| `mxgateway.commands.succeeded` | `method` | Commands acknowledged with success by the worker. |
| `mxgateway.commands.failed` | `method`, `category` | Command failures, where `category` is the `WorkerClientErrorCode` or exception type name. |
| `mxgateway.events.received` | `family` | Worker events accepted into the event pipeline. |
| `mxgateway.queues.overflows` | `queue` | Drops when a bounded queue rejects a message (e.g. `grpc-event-stream`). |
| `mxgateway.faults` | `category` | Faults reported by session, event, or worker code paths. The category is a `SessionManagerErrorCode` or `WorkerClientErrorCode` name. |
| `mxgateway.workers.killed` | `reason` | Forced terminations of worker processes. |
| `mxgateway.workers.exited` | `reason` | Clean or fault-driven worker exits. |
| `mxgateway.heartbeats.failed` | `session_id` | Worker heartbeat misses tracked per session. |
| `mxgateway.grpc.streams.disconnected` | `reason` | Detachments of the dashboard or client gRPC event stream. |
| `mxgateway.retries.attempted` | `area` | Resilience retries executed by gateway components. |
### Histograms
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
```csharp
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
```
| Instrument | Tags | What it measures |
|------------|------|------------------|
| `mxgateway.workers.startup.duration` | none | Time from `WorkerClient` launch to worker-ready. |
| `mxgateway.commands.duration` | `method`, optional `category` | Command round-trip time. The `category` tag is added on failure so success and failure latencies stay distinguishable. |
| `mxgateway.events.stream_send.duration` | `family` | Time spent writing each public event to the gRPC response stream in `MxAccessGatewayService.StreamEvents`. |
### Observable Gauges
Observable gauges are pull-based; the `Meter` invokes the supplied callback whenever a listener samples it. Each callback re-acquires `_syncRoot` so the gauge value matches the snapshot taken at the same instant.
| Instrument | Source field | Description |
|------------|--------------|-------------|
| `mxgateway.sessions.open` | `_openSessions` | Currently open sessions tracked by `SessionManager`. |
| `mxgateway.workers.running` | `_workersRunning` | Worker clients in a running state. |
| `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepth` | Last reported depth of the worker-side event queue. |
| `mxgateway.events.grpc_stream_queue.depth` | `_grpcEventStreamQueueDepth` | Backlog held by `EventStreamService` for the active gRPC stream consumer. |
## Snapshot Shape
`GatewayMetricsSnapshot` is the immutable view of the same state, returned by `GatewayMetrics.GetSnapshot()` while holding `_syncRoot`. The dictionaries are copied so the caller can iterate without further synchronisation. The dashboard service is the primary consumer.
```csharp
public sealed record GatewayMetricsSnapshot(
int OpenSessions,
int WorkersRunning,
int WorkerEventQueueDepth,
int GrpcEventStreamQueueDepth,
long SessionsOpened,
long SessionsClosed,
long CommandsStarted,
long CommandsSucceeded,
long CommandsFailed,
long EventsReceived,
long QueueOverflows,
long Faults,
long WorkerKills,
long WorkerExits,
long HeartbeatFailures,
long StreamDisconnects,
long RetryAttempts,
IReadOnlyDictionary<string, long> CommandFailuresByMethod,
IReadOnlyDictionary<string, long> EventsByFamily,
IReadOnlyDictionary<string, long> EventsBySession,
IReadOnlyDictionary<string, long> RetryAttemptsByArea);
```
The scalar fields mirror the counters and gauges. The four dictionaries provide the breakdowns that counter tags would otherwise require an exporter to aggregate:
- `CommandFailuresByMethod` keys by gRPC method name.
- `EventsByFamily` keys by event family (the `Family` enum on a worker event).
- `EventsBySession` keys by `sessionId`; entries are removed via `RemoveSessionEvents` when a session closes so the map does not grow without bound.
- `RetryAttemptsByArea` keys by the resilience `area` tag, e.g. `worker_startup`.
`EventsReceived` is read with `Interlocked.Read(ref _eventsReceived)` because `EventReceived` increments it via `Interlocked.Increment` outside the lock to keep the event-ingestion path non-blocking.
## Recording Sites
The recording call sites describe the code paths that write into each instrument. This mapping makes it easier to trace an unexpected counter reading back to a subsystem.
### Session manager
`Sessions/SessionManager.cs` emits session lifecycle and fault counters:
```csharp
_metrics.SessionOpened();
...
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
...
_metrics.SessionClosed();
...
_metrics.SessionRemoved();
...
_metrics.Fault(SessionManagerErrorCode.CloseFailed.ToString());
...
_metrics.RemoveSessionEvents(session.SessionId);
```
`SessionRemoved` decrements the open-session gauge without incrementing the closed counter, which covers cases where a session is evicted rather than closed by the client.
### Worker client
`Workers/WorkerClient.cs` records command throughput, worker lifecycle, heartbeat failures, and the worker-side event queue depth:
- `CommandStarted(method)` and `CommandSucceeded(method, duration)` / `CommandFailed(method, category, duration)` around the worker request/response pair.
- `WorkerStarted(startupDuration)` once the worker reports ready.
- `RecordWorkerStoppedOnce` calls `WorkerStopped(reason)` exactly once per worker, guarding against double-counting on simultaneous fault and exit signals.
- `WorkerKilled(reason)` when the client forcibly terminates the worker.
- `HeartbeatFailed(SessionId)` per missed heartbeat.
- `SetWorkerEventQueueDepth(queueDepth)` after each event ingest.
- `EventReceived(SessionId, workerEvent.Event.Family.ToString())` for each worker event.
### Worker process launcher
`Workers/WorkerProcessLauncher.cs` records process-level kills and startup retries:
```csharp
_metrics.WorkerKilled(reason);
...
_metrics.RetryAttempted("worker_startup");
```
The `worker_startup` tag is hard-coded so the `RetryAttemptsByArea` snapshot reports launcher retries distinctly from other resilience areas.
### Session worker client factory
`Sessions/SessionWorkerClientFactory.cs` records the worker kill that follows a failed `OpenSession` handshake:
```csharp
_metrics.WorkerKilled("OpenSessionFailed");
```
This is the only fault path where the factory itself owns the kill decision; once the worker is bound to a session, the `WorkerClient` becomes responsible for lifecycle metrics.
### gRPC event stream service
`Grpc/EventStreamService.cs` records the dashboard/client event-stream backlog and disconnect counters:
```csharp
metrics.AdjustGrpcEventStreamQueueDepth(1);
...
metrics.AdjustGrpcEventStreamQueueDepth(-1);
...
metrics.AdjustGrpcEventStreamQueueDepth(-remainingDepth);
metrics.StreamDisconnected("Detached");
...
metrics.QueueOverflow("grpc-event-stream");
metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
...
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
```
The service tracks per-message enqueues and dequeues, so `AdjustGrpcEventStreamQueueDepth` updates the aggregate stream backlog. The `Math.Max(0, ...)` clamp inside the adjuster prevents a negative depth if the bookkeeping ever drifts.
`Grpc/MxAccessGatewayService.cs` records gRPC event send latency around each response-stream write:
```csharp
Stopwatch stopwatch = Stopwatch.StartNew();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
```
## Dashboard Consumption
`Dashboard/DashboardSnapshotService.cs` calls `_metrics.GetSnapshot()` once per `GetSnapshot` invocation and projects it into the dashboard transport types together with the session registry view. The dashboard receives a single, internally consistent snapshot per tick rather than reading individual counters at separate times. See [Gateway Dashboard Design](./gateway-dashboard-design.md) and [Dashboard Interface Design](./DashboardInterfaceDesign.md) for the projection rules and wire format.
## Related Documentation
- [Gateway Dashboard Design](./gateway-dashboard-design.md)
- [Dashboard Interface Design](./DashboardInterfaceDesign.md)
- [Sessions](./Sessions.md)
+271
View File
@@ -0,0 +1,271 @@
# Gateway Sessions
The sessions subsystem owns the in-memory representation of an active gateway-to-worker pairing and coordinates its lifecycle from open through close. Each `GatewaySession` corresponds to exactly one MXAccess worker process connected over a dedicated named pipe.
## Overview
A session is the gateway-side handle that callers use to invoke worker commands, stream worker events, and tear the worker down. The subsystem is split between the per-session state machine (`GatewaySession`), an in-memory directory (`SessionRegistry`), the orchestrator that opens and closes sessions (`SessionManager`), the worker construction step (`SessionWorkerClientFactory`), and a hosted service that drains sessions during host shutdown (`SessionShutdownHostedService`).
All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClientFactory`) plus `SessionShutdownHostedService` are wired as singletons by `SessionServiceCollectionExtensions.AddGatewaySessions`.
## Key Types
### GatewaySession
`GatewaySession` is a sealed class that holds the identity, configured timeouts, worker client reference, and current `SessionState` for one session. State is protected by a private `_syncRoot` lock so that property reads and transitions are observed atomically by concurrent gRPC calls and the lease sweeper.
The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxaccess-gateway-{ProcessId}-{SessionId}`. Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed.
`SessionState` itself is the protobuf-generated enum from `MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire.
```csharp
public void TransitionTo(SessionState nextState)
{
lock (_syncRoot)
{
if (_state is SessionState.Closed)
{
return;
}
if (_state is SessionState.Faulted && nextState is not SessionState.Closed)
{
return;
}
_state = nextState;
}
}
```
`Closed` is terminal and `Faulted` only allows a transition to `Closed`. This guards against late callbacks (worker exit, heartbeat timeout) re-animating a session that is already torn down.
### SessionManager (ISessionManager)
`SessionManager` is the orchestrator. It exposes `OpenSessionAsync`, `TryGetSession`, `InvokeAsync`, `ReadEventsAsync`, `CloseSessionAsync`, `CloseExpiredLeasesAsync`, and `ShutdownAsync`. It composes `ISessionRegistry`, `ISessionWorkerClientFactory`, `GatewayMetrics`, and `GatewayOptions`.
Concurrency is bounded by a `SemaphoreSlim` initialized to `GatewayOptions.Sessions.MaxSessions`. Open requests that exceed the bound throw `SessionManagerException` with `SessionLimitExceeded` rather than queuing; the caller is expected to retry.
```csharp
private void EnsureSessionCapacity()
{
if (!_sessionSlots.Wait(0))
{
throw new SessionManagerException(
SessionManagerErrorCode.SessionLimitExceeded,
$"Gateway session limit {_options.Sessions.MaxSessions} has been reached.");
}
}
```
`SessionManager` also defines three close-reason constants — `DefaultCloseReason` (`"client-close"`), `GatewayShutdownReason` (`"gateway-shutdown"`), and `LeaseExpiredReason` (`"lease-expired"`) — so that the metrics and worker shutdown paths agree on a fixed vocabulary.
### SessionRegistry (ISessionRegistry)
`SessionRegistry` is a thin wrapper over a `ConcurrentDictionary<string, GatewaySession>` keyed by session id with `StringComparer.Ordinal`. `Snapshot` materializes the values into an array so iteration callers (lease sweeper, shutdown) do not race with concurrent `TryAdd` and `TryRemove` calls.
`ActiveCount` filters out sessions whose state is `Closed`; this is consumed by metrics and the dashboard, where `Count` would otherwise momentarily over-report during teardown.
### SessionWorkerClientFactory (ISessionWorkerClientFactory)
`SessionWorkerClientFactory.CreateAsync` is the only path that builds an `IWorkerClient`. It drives the session through the protobuf `SessionState` substates in order: `StartingWorker`, `WaitingForPipe`, `Handshaking`, `InitializingWorker`. The substates are wire-visible so the dashboard and clients can render startup progress.
A linked `CancellationTokenSource` enforces `session.StartupTimeout`. If startup fails or times out, the factory either kills the partially-constructed `WorkerClient` or, if the client was never built, kills the launched process and disposes the named pipe before rethrowing. A pure timeout is rewritten as `TimeoutException` so callers can distinguish it from caller-driven cancellation:
```csharp
if (exception is OperationCanceledException
&& startupCancellation.IsCancellationRequested
&& !cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"Worker session {session.SessionId} did not complete startup within {session.StartupTimeout}.",
exception);
}
```
The named pipe is created with `maxNumberOfServerInstances: 1` so a second worker cannot connect to the same pipe name even if the first launch is still pending. Combined with the per-session nonce passed to the worker, this is the gateway's defense against a foreign process answering a pipe.
### SessionShutdownHostedService
`SessionShutdownHostedService` is an `IHostedService` whose only job is to call `ISessionManager.ShutdownAsync` from `StopAsync`. It catches `OperationCanceledException` triggered by the host shutdown timeout and logs a warning so that an over-running shutdown does not surface as an unhandled exception.
### SessionOpenRequest
`SessionOpenRequest` is the gateway-internal record passed to `OpenSessionAsync`. It is constructed from the wire-level `OpenSessionRequest` via `SessionOpenRequest.FromContract`. Keeping a separate internal record means the gRPC layer can normalize input (defaulting backend, sanitizing strings) without leaking generated proto types into `SessionManager`.
```csharp
public sealed record SessionOpenRequest(
string? RequestedBackend,
string? ClientSessionName,
string? ClientCorrelationId,
Duration? CommandTimeout)
{
public static SessionOpenRequest FromContract(OpenSessionRequest request)
{
ArgumentNullException.ThrowIfNull(request);
return new SessionOpenRequest(
request.RequestedBackend,
request.ClientSessionName,
request.ClientCorrelationId,
request.CommandTimeout);
}
}
```
### SessionCloseResult
`SessionCloseResult` is the record returned from a successful close. `AlreadyClosed` distinguishes "this call closed the session" from "the session was already closed when we acquired the close lock", which the metrics layer uses to avoid double-counting.
```csharp
public sealed record SessionCloseResult(
string SessionId,
SessionState FinalState,
bool AlreadyClosed);
```
### SessionCloseStartedException
`SessionCloseStartedException` is `internal` and is only thrown from inside `GatewaySession.CloseAsync` when the close path has already begun mutating worker state and a subsequent step fails. `SessionManager.CloseSessionCoreAsync` catches it, marks the session faulted, increments the close-failed metric, removes the session from the registry, and rethrows it wrapped as `SessionManagerException` with `CloseFailed`. The intermediate type exists so the public API surface only ever exposes `SessionManagerException`.
### SessionManagerException and SessionManagerErrorCode
`SessionManagerException` is the single public error type emitted from this subsystem; the code is carried in the `ErrorCode` property and is also surfaced to metrics tags via `SessionManagerErrorCode.ToString()`.
| Code | Meaning |
|------|---------|
| `SessionNotFound` | The session id is not in the registry. |
| `SessionNotReady` | The session or its `IWorkerClient` is not in `Ready` state. |
| `EventSubscriberAlreadyActive` | A second event subscriber attached when only one is allowed. |
| `EventQueueOverflow` | Reserved for the worker event channel overflow path. |
| `SessionLimitExceeded` | `MaxSessions` is in use. |
| `OpenFailed` | `OpenSessionAsync` failed; the inner exception carries the cause. |
| `CloseFailed` | A close started but did not complete cleanly; the session is removed and faulted. |
## Lifecycle
### Open
`SessionManager.OpenSessionAsync` allocates a session slot, builds the `GatewaySession`, registers it, and asks the factory to bring up the worker. Failures roll back every preceding step:
```csharp
catch (Exception exception)
{
session?.MarkFaulted(exception.Message);
if (session is not null)
{
_registry.TryRemove(session.SessionId, out _);
await session.DisposeAsync().ConfigureAwait(false);
}
ReleaseSessionSlot();
_metrics.Fault(SessionManagerErrorCode.OpenFailed.ToString());
_logger.LogWarning(
exception,
"Failed to open gateway session {SessionId}.",
session?.SessionId ?? "<not-created>");
throw new SessionManagerException(
SessionManagerErrorCode.OpenFailed,
session is null ? "Failed to create session." : $"Failed to open session {session.SessionId}.",
exception);
}
```
The order — fault, deregister, dispose, release slot, record metric, log, rethrow — matters because releasing the semaphore before disposal would let the next open race the worker process tear-down on the same machine.
### Run
While `Ready`, callers reach the worker through `SessionManager.InvokeAsync` or `ReadEventsAsync`. Both delegate to `GatewaySession`, which checks the state under lock and updates `LastClientActivityAt` on every invocation. `GatewaySession` also exposes typed bulk helpers (`AddItemBulkAsync`, `SubscribeBulkAsync`, etc.) that wrap `WorkerCommand` round-trips and translate non-`Ok` `ProtocolStatus` replies into `SessionManagerException` with `SessionNotReady`.
Event streaming uses `AttachEventSubscriber` which returns a disposable lease. When `allowMultipleSubscribers` is false the second attach throws `EventSubscriberAlreadyActive`; this prevents two gRPC streams from racing on the same worker event channel.
`ExtendLease` and `IsLeaseExpired` cooperate with `SessionManager.CloseExpiredLeasesAsync`, which iterates a registry snapshot and closes any session whose lease has expired with `LeaseExpiredReason`.
### Close
`GatewaySession.CloseAsync` is serialized by a per-session `SemaphoreSlim` (`_closeLock`). It transitions to `Closing`, asks the worker client to shut down within `ShutdownTimeout`, and on success transitions to `Closed`. If `WorkerClient.ShutdownAsync` throws, the session falls back to `IWorkerClient.Kill` (forced close):
```csharp
if (_workerClient is not null)
{
try
{
await _workerClient.ShutdownAsync(ShutdownTimeout, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
try
{
_workerClient.Kill(reason);
}
catch (Exception killException)
{
throw new SessionCloseStartedException(
$"Session {SessionId} close failed after worker shutdown started.",
new AggregateException(exception, killException));
}
throw;
}
}
```
If both graceful shutdown and the kill fall-back fail, the original and kill exceptions are bundled into an `AggregateException` and surfaced as `SessionCloseStartedException`. `SessionManager.CloseSessionCoreAsync` then translates that into a `SessionManagerException` with `CloseFailed` and removes the session.
`GatewaySession.KillWorker` is the unconditional forced-close path used by shutdown when graceful close itself throws.
## Shutdown Coordination
`SessionShutdownHostedService.StopAsync` calls `SessionManager.ShutdownAsync`, which closes every registered session with `GatewayShutdownReason`. The shutdown loop catches per-session exceptions, calls `KillWorker`, and removes the session so that one stuck worker cannot block the rest of the host:
```csharp
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
foreach (GatewaySession session in _registry.Snapshot())
{
try
{
await CloseSessionCoreAsync(session, GatewayShutdownReason, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Graceful shutdown failed for session {SessionId}; killing worker.",
session.SessionId);
if (_registry.TryGet(session.SessionId, out _))
{
session.KillWorker(GatewayShutdownReason);
await RemoveSessionAsync(session).ConfigureAwait(false);
}
}
}
}
```
Iterating over `Snapshot` rather than the live dictionary lets `RemoveSessionAsync` mutate the registry inside the loop without throwing.
## Dependency Injection
`SessionServiceCollectionExtensions.AddGatewaySessions` registers the four singletons and the hosted service:
```csharp
public static IServiceCollection AddGatewaySessions(this IServiceCollection services)
{
services.AddSingleton<ISessionRegistry, SessionRegistry>();
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
services.AddSingleton<ISessionManager, SessionManager>();
services.AddHostedService<SessionShutdownHostedService>();
return services;
}
```
The registry must be a singleton because its `ConcurrentDictionary` is the source of truth for session state across the gRPC service, the lease sweeper, the dashboard, and the shutdown hosted service. Registering `SessionShutdownHostedService` last ensures it is constructed after `ISessionManager` and therefore drains sessions during host stop.
## Related Documentation
- [Gateway Process Design](./gateway-process-design.md)
- [Gateway Configuration](./GatewayConfiguration.md)
- [Worker Process Launcher](./WorkerProcessLauncher.md)
+223
View File
@@ -0,0 +1,223 @@
# Worker Bootstrap
The bootstrap layer parses the command-line arguments and environment variables passed to the `MxGateway.Worker` process, validates them against the gateway contract, and produces either a populated `WorkerOptions` instance or a structured failure that maps to a `WorkerExitCode`.
## Overview
The worker process is a short-lived child of the gateway. The gateway side of this contract lives in [WorkerProcessLauncher](./WorkerProcessLauncher.md). On the worker side, `Program.cs` is a single line that delegates to `WorkerApplication.Run(args)`:
```csharp
using MxGateway.Worker;
return WorkerApplication.Run(args);
```
`WorkerApplication.Run` constructs the bootstrap dependencies (`EnvironmentVariableWorkerEnvironment`, `WorkerConsoleLogger` writing to `Console.Error`, and a `WorkerPipeClient`), runs `WorkerOptionsParser`, and routes the resulting `WorkerBootstrapResult` either into the pipe client or into a non-zero exit. Splitting parsing from process wiring lets tests substitute fakes for the environment, logger, and pipe client without spawning a child process.
## Worker Options
`WorkerOptions` is the validated input contract for a worker session. The gateway hands every field to the worker; the worker never reads configuration files.
```csharp
public sealed class WorkerOptions
{
public const string NonceEnvironmentVariableName = "MXGATEWAY_WORKER_NONCE";
public WorkerOptions(
string sessionId,
string pipeName,
uint protocolVersion,
string nonce)
{
SessionId = sessionId;
PipeName = pipeName;
ProtocolVersion = protocolVersion;
Nonce = nonce;
}
public string SessionId { get; }
public string PipeName { get; }
public uint ProtocolVersion { get; }
public string Nonce { get; }
}
```
### Required inputs
All four fields are required. Three arrive on the command line and one arrives via environment variable:
| Source | Name | Maps to |
|--------|------|---------|
| Argument | `--session-id` | `SessionId` |
| Argument | `--pipe-name` | `PipeName` |
| Argument | `--protocol-version` | `ProtocolVersion` |
| Env var | `MXGATEWAY_WORKER_NONCE` | `Nonce` |
The nonce travels via environment variable rather than an argument because process command lines are visible to other users on Windows through `wmic`, `Get-CimInstance Win32_Process`, and the kernel object table; environment variables of another process are not. Treating the nonce as a credential keeps it off the command line.
There are no optional options. An unknown flag, a flag without a value, or a flag whose value starts with `--` is reported as an error rather than silently ignored.
## The Parser
`WorkerOptionsParser` walks `args` once, collects values into a case-insensitive dictionary, and accumulates errors so the caller sees every problem in a single failure rather than fixing them one at a time.
```csharp
for (int index = 0; index < args.Length; index++)
{
string arg = args[index];
if (!IsKnownOption(arg))
{
errors.Add($"Unknown option '{arg}'.");
continue;
}
if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
errors.Add($"Option '{arg}' requires a value.");
continue;
}
values[arg] = args[index + 1];
index++;
}
```
After argument scanning, the parser cross-checks the protocol version against `GatewayContractInfo.WorkerProtocolVersion`. A version that parses as a `uint` but does not match the contract value is a hard failure with `WorkerExitCode.InvalidProtocolVersion`, separate from `InvalidArguments`, so the gateway can distinguish a malformed launch from a version mismatch and report a useful upgrade message.
The nonce is read last so that argument-shape errors are reported before the parser asks the environment for a secret it might not need.
## Bootstrap Result
`WorkerBootstrapResult` is a discriminated success/failure carrier. `Options` is non-null when `Succeeded` is true; `Errors` is populated only on failure.
```csharp
public static WorkerBootstrapResult Success(WorkerOptions options)
{
return new WorkerBootstrapResult(WorkerExitCode.Success, options, []);
}
public static WorkerBootstrapResult Failure(WorkerExitCode exitCode, IEnumerable<string> errors)
{
return new WorkerBootstrapResult(exitCode, null, errors.ToArray());
}
```
`Succeeded` is defined as `ExitCode == WorkerExitCode.Success` rather than as a separate flag, so the exit code and the success state cannot disagree.
## Exit Codes
`WorkerExitCode` is the worker process's exit contract. The gateway-side launcher decodes these values to decide whether a relaunch is safe.
| Value | Numeric | Produced when |
|-------|---------|---------------|
| `Success` | 0 | The pipe session ran to a clean close. |
| `UnexpectedFailure` | 1 | Any unhandled exception not matched by a more specific catch. |
| `InvalidArguments` | 2 | One or more `--session-id`, `--pipe-name`, or `--protocol-version` errors (missing, empty, unknown flag, or no value). |
| `InvalidProtocolVersion` | 3 | `--protocol-version` is not a `uint` or does not equal `GatewayContractInfo.WorkerProtocolVersion`. |
| `MissingNonce` | 4 | The `MXGATEWAY_WORKER_NONCE` environment variable is null, empty, or whitespace. |
| `PipeConnectionFailed` | 5 | An `IOException` or `TimeoutException` escapes the pipe client. |
| `ProtocolViolation` | 6 | A `WorkerFrameProtocolException` escapes the pipe client. |
`InvalidArguments`, `InvalidProtocolVersion`, and `MissingNonce` originate in the parser; the others originate in `WorkerApplication.Run`'s `try/catch` around the pipe client.
## Environment Abstraction
`IWorkerEnvironment` exists so tests can supply a fake nonce without mutating the real process environment, which would be a shared mutable global across parallel test runs.
```csharp
public interface IWorkerEnvironment
{
string? GetEnvironmentVariable(string name);
}
public sealed class EnvironmentVariableWorkerEnvironment : IWorkerEnvironment
{
public string? GetEnvironmentVariable(string name)
{
return Environment.GetEnvironmentVariable(name);
}
}
```
The production binding in `WorkerApplication.Run(string[])` is `EnvironmentVariableWorkerEnvironment`, which is a thin pass-through to `System.Environment.GetEnvironmentVariable`.
## Logging
The worker writes structured key/value lines to standard error. Standard error is used rather than standard output because the gateway side reads worker stdout for diagnostic capture only, while stderr is reserved for log output that does not interfere with any future stdout-based channel.
### The logger contract
`IWorkerLogger` exposes only `Information` and `Error`. There is no `Debug` or `Trace` level, because the worker is launched per session and verbose tracing belongs to the gateway-side launcher.
```csharp
public interface IWorkerLogger
{
void Information(string eventName, IReadOnlyDictionary<string, object?> fields);
void Error(string eventName, IReadOnlyDictionary<string, object?> fields);
}
```
`WorkerConsoleLogger` formats each call as `level=<Level> event=<EventName> key=value key=value` after running the field dictionary through `WorkerLogRedactor`:
```csharp
private void Write(
string level,
string eventName,
IReadOnlyDictionary<string, object?> fields)
{
Dictionary<string, object?> redactedFields = WorkerLogRedactor.RedactFields(fields);
string fieldText = string.Join(
" ",
redactedFields.Select(field => $"{field.Key}={FormatValue(field.Value)}"));
_writer.WriteLine($"level={level} event={eventName} {fieldText}".TrimEnd());
}
```
### 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]`:
```csharp
private static readonly string[] SensitiveFieldNameParts =
[
"nonce",
"secret",
"password",
"token",
"credential",
"apikey",
"api_key",
];
```
The match is on substrings of the field name rather than an exact list, so a field called `auth_token` or `user_password` is redacted automatically without each call site having to remember to opt in. `null` values pass through unchanged so the absence of a value is still visible in logs.
## How `Program.cs` Consumes The Result
`WorkerApplication.Run` is the single consumer of `WorkerBootstrapResult`. On failure it logs a `WorkerBootstrapFailed` event and returns the numeric `ExitCode` directly:
```csharp
WorkerOptionsParser parser = new(environment);
WorkerBootstrapResult result = parser.Parse(args);
if (!result.Succeeded)
{
logger.Error("WorkerBootstrapFailed", new Dictionary<string, object?>
{
["exit_code"] = result.ExitCode,
["errors"] = string.Join(";", result.Errors),
});
return (int)result.ExitCode;
}
```
On success it logs `WorkerBootstrapSucceeded` with the session fields (the `nonce` field is redacted by `WorkerLogRedactor` because of its name), hands the `WorkerOptions` to `IWorkerPipeClient.RunAsync`, and waits synchronously. The `try/catch` around the pipe call maps `WorkerFrameProtocolException` to `ProtocolViolation`, `IOException`/`TimeoutException` to `PipeConnectionFailed`, and any other exception to `UnexpectedFailure`, so every code path through `Run` returns one of the values in `WorkerExitCode`.
## Related Documentation
- [Worker Process Launcher](./WorkerProcessLauncher.md)
- [Worker STA](./WorkerSta.md)
- [Worker Frame Protocol](./WorkerFrameProtocol.md)
+262
View File
@@ -0,0 +1,262 @@
# Worker Conversion Layer
The conversion layer in `MxGateway.Worker.Conversion` projects COM `VARIANT` payloads, `HRESULT` codes, and `MXSTATUS_PROXY` records into the protobuf wire types in `MxGateway.Contracts.Proto`. The design is parity-first: every projection preserves enough raw metadata that the original COM observation can be reconstructed downstream.
## 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.
The layer is split into three concerns:
- Value projection: `VariantConverter`
- HRESULT projection: `HResultConverter`, `HResultConversion`
- Status projection: `MxStatusProxyConverter`, `MxStatusDetailText`, `MxStatusConversionException`
## VariantConverter
`VariantConverter` projects scalar and array `VARIANT` payloads delivered by COM interop into `MxValue` and `MxArray`. It accepts an optional `expectedDataType` so that an MXAccess attribute hint (for example `MxDataType.Time` for a 64-bit FILETIME) overrides the default CLR-driven projection.
### Scalar projection
Scalars dispatch on `Type.GetTypeCode` and populate the matching field in the `MxValue` `oneof`. Each branch records both the typed value and the source `VARIANT` tag in `VariantType`, so consumers can distinguish a `short` from an `int` even after both project to `MxDataType.Integer`.
```csharp
case TypeCode.Boolean:
return new MxValue
{
DataType = MxDataType.Boolean,
VariantType = variantType,
BoolValue = (bool)value,
};
case TypeCode.Byte:
case TypeCode.SByte:
case TypeCode.Int16:
case TypeCode.UInt16:
case TypeCode.Int32:
return new MxValue
{
DataType = MxDataType.Integer,
VariantType = variantType,
Int32Value = System.Convert.ToInt32(value, CultureInfo.InvariantCulture),
};
```
`Decimal` and 64-bit unsigned integers that exceed `long.MaxValue` cannot project losslessly. The converter still emits a typed best-effort value but attaches a `RawDiagnostic` so the loss is recorded on the wire rather than silently absorbed.
```csharp
case TypeCode.Decimal:
return new MxValue
{
DataType = MxDataType.Double,
VariantType = variantType,
DoubleValue = System.Convert.ToDouble(value, CultureInfo.InvariantCulture),
RawDiagnostic = "Decimal value projected to double.",
};
```
Time-typed integers use the `expectedDataType` hint. `ConvertInt64Scalar` interprets a `long` as a Windows FILETIME when the caller asks for `MxDataType.Time`, otherwise it stays an integer. `ConvertUInt64Scalar` falls through to a raw projection if the value exceeds `long.MaxValue`.
### Null and missing values
`VARIANT` distinguishes `VT_EMPTY` from `VT_NULL`. The converter preserves both by inspecting whether the input is `DBNull` or a CLR null reference, and tags the result with `IsNull = true` so consumers do not misread a default value as data.
```csharp
private static MxValue CreateNullValue(
object? value,
MxDataType expectedDataType)
{
return new MxValue
{
DataType = expectedDataType == MxDataType.Unspecified ? MxDataType.NoData : expectedDataType,
VariantType = value is DBNull ? "VT_NULL" : "VT_EMPTY",
IsNull = true,
};
}
```
### Array projection
`ConvertArray` records the rank and per-dimension lengths so multi-dimensional `SAFEARRAY` shapes survive the round trip. The element type is resolved from the caller-supplied hint or the CLR element type via `ResolveArrayElementDataType`, then dispatched to the matching typed builder (`ConvertBoolArray`, `ConvertInt64Array`, `ConvertTimestampArray`, and so on).
```csharp
for (int dimension = 0; dimension < array.Rank; dimension++)
{
mxArray.Dimensions.Add((uint)array.GetLength(dimension));
}
System.Type? elementType = array.GetType().GetElementType();
MxDataType elementDataType = ResolveArrayElementDataType(elementType, expectedElementDataType);
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.
```csharp
default:
mxArray.ElementDataType = MxDataType.Unknown;
mxArray.RawElementDataType = (int)expectedElementDataType;
mxArray.RawDiagnostic = CreateRawDiagnostic(array);
mxArray.RawValues = ConvertRawArray(array);
return mxArray;
```
### Variant type names
`GetVariantTypeName` maps CLR types to canonical `VT_*` strings (`VT_BOOL`, `VT_I4`, `VT_BSTR`, `VT_DATE`, and so on). Unmapped CLR types fall back to `CLR:<FullName>` so the wire format never silently invents a `VT_*` tag it cannot justify. Array element tags are wrapped as `SAFEARRAY(<element>)` by `CreateArrayVariantType`.
### Why lossless preservation matters
The MXAccess engine returns values whose semantic type only fully resolves after consulting the engine's own attribute metadata. Clients that round-trip these values through the gateway (replay, parity fixtures, diagnostics) need the original `VT_*` tag, the engine-declared `MxDataType`, and any conversion diagnostic; otherwise edge cases such as decimal-to-double rounding, ulong overflow, or an unknown SAFEARRAY element type become invisible bugs. Storing both the typed projection and the raw fields in the same `MxValue`/`MxArray` lets cross-language clients recover the original observation byte-for-byte where possible and detect lossy cases where it is not.
## HResultConverter and HResultConversion
`HResultConverter.Convert` wraps any `Exception` thrown across the COM boundary. It prefers `COMException.ErrorCode` over `Exception.HResult` because the runtime sometimes overwrites `Exception.HResult` while marshalling, and the `ErrorCode` field is the value the COM call actually returned.
```csharp
public HResultConversion Convert(Exception exception)
{
if (exception is null)
{
throw new ArgumentNullException(nameof(exception));
}
int hresult = exception is COMException comException
? comException.ErrorCode
: exception.HResult;
return new HResultConversion(
hresult,
CreateProtocolStatus(hresult, exception),
CreateSafeDiagnosticMessage(exception));
}
```
`CreateProtocolStatus` maps `0` (`S_OK`) to `ProtocolStatusCode.Ok` and any non-zero HRESULT to `ProtocolStatusCode.MxaccessFailure`. The status `Message` includes the exception type name and the formatted HRESULT (`HRESULT 0x<8 hex digits>`), giving downstream operators the same identifier they would see in COM event logs.
`HResultConversion` is an immutable carrier with three fields:
| Field | Purpose |
|-------|---------|
| `HResult` | Raw signed 32-bit HRESULT, suitable for inclusion in a wire reply |
| `ProtocolStatus` | The `ProtocolStatus` projection used in command replies |
| `DiagnosticMessage` | A safe, fully-qualified diagnostic string for logs |
The diagnostic message is built from the exception type's `FullName` and the formatted HRESULT only. It deliberately omits `Exception.Message` so user-supplied or localized strings do not leak into worker log channels that may be shipped to less trusted destinations.
## MxStatusProxyConverter
`MxStatusProxyConverter` projects MXAccess `MXSTATUS_PROXY` records into the `MxStatusProxy` proto message. Because the COM struct is exposed via interop reflection rather than a typed binding, the converter reads the `success`, `category`, `detectedBy`, and `detail` fields by name through `FieldInfo`.
```csharp
public MxStatusProxy Convert(object status)
{
if (status is null)
{
throw new ArgumentNullException(nameof(status));
}
Type statusType = status.GetType();
int success = ReadInt32Field(status, statusType, "success");
int rawCategory = ReadInt32Field(status, statusType, "category");
int rawDetectedBy = ReadInt32Field(status, statusType, "detectedBy");
int detail = ReadInt32Field(status, statusType, "detail");
return new MxStatusProxy
{
Success = success,
Category = MapCategory(rawCategory),
DetectedBy = MapSource(rawDetectedBy),
Detail = detail,
RawCategory = rawCategory,
RawDetectedBy = rawDetectedBy,
DiagnosticText = MxStatusDetailText.Lookup(detail),
};
}
```
`MapCategory` and `MapSource` translate the integer codes documented for `MXSTATUS_PROXY` (for example `0 = Ok`, `3 = CommunicationError`, `0 = RequestingLmx`, `5 = RespondingAutomationObject`) into typed enums. The original integers are preserved alongside the typed projection in `RawCategory` and `RawDetectedBy`, so any code the runtime emits outside the documented range is still observable.
### Status arrays
`ConvertMany` walks an inbound `Array` of status structs and emits `IReadOnlyList<MxStatusProxy>`. Null entries become explicit `Unknown`/`Unknown` placeholders with a diagnostic text rather than being dropped, so the index of each status still aligns with the index of the value or item handle it describes.
```csharp
foreach (object? status in statuses)
{
if (status is null)
{
converted.Add(new MxStatusProxy
{
Category = MxStatusCategory.Unknown,
DetectedBy = MxStatusSource.Unknown,
DiagnosticText = "Null MXSTATUS_PROXY entry.",
});
continue;
}
converted.Add(Convert(status));
}
```
### 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`.
### Completion-only status fallback
When MXAccess returns a status payload that is not a recognizable `MXSTATUS_PROXY` struct (for example a completion-only byte buffer from older runtimes), `PreserveCompletionOnlyStatusBytes` hex-encodes the raw bytes so they survive transport even though they cannot be typed.
```csharp
public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes)
{
if (statusBytes is null)
{
throw new ArgumentNullException(nameof(statusBytes));
}
return $"completion_only_status_hex={BitConverter.ToString(statusBytes).Replace("-", string.Empty)}";
}
```
## MxStatusDetailText
`MxStatusDetailText` is an internal lookup that maps known `MXSTATUS_PROXY.detail` codes to short human-readable strings (for example `28 = "Index out of range"`, `42 = "Unable to convert string"`, `8017 = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification"`). `MxStatusProxyConverter.Convert` calls `Lookup` and writes the result to `DiagnosticText`. Unknown codes return `string.Empty`, leaving the numeric `Detail` field as the authoritative identifier.
The mapping covers the engine-error range documented for MXAccess (16-50, 56-61, 541-542, 8017). Adding entries here is the supported way to enrich wire-level diagnostics without changing the proto schema.
## MxStatusConversionException
`MxStatusConversionException` is the worker-internal signal that a status struct could not be projected at all - for example, the interop type is missing one of the required fields, or the field is null. `MxStatusProxyConverter.ReadInt32Field` raises it with a message that names both the offending CLR type and the missing field.
```csharp
private static int ReadInt32Field(
object value,
Type valueType,
string fieldName)
{
FieldInfo? field = valueType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public);
if (field is null)
{
throw new MxStatusConversionException(
$"Status object type '{valueType.FullName}' does not expose required field '{fieldName}'.");
}
object? fieldValue = field.GetValue(value);
if (fieldValue is null)
{
throw new MxStatusConversionException(
$"Status object field '{fieldName}' on type '{valueType.FullName}' is null.");
}
return System.Convert.ToInt32(fieldValue, CultureInfo.InvariantCulture);
}
```
The exception is distinct from `COMException` so the worker's command pipeline can route it through a dedicated handler. Callers typically translate it into a `ProtocolStatus` with `ProtocolStatusCode.MxaccessFailure` and a synthetic HRESULT, then attach the exception message as a `RawDiagnostic` rather than letting the failure surface as a generic worker error.
## Related Documentation
- [MXAccess Worker Instance Design](./mxaccess-worker-instance-design.md)
- [Contracts](./Contracts.md)
- [Worker STA Threading](./WorkerSta.md)
+157
View File
@@ -0,0 +1,157 @@
# Worker STA Runtime
The worker STA runtime owns the dedicated single-threaded apartment thread that hosts the MXAccess COM object, runs a Windows message pump for COM event delivery, and serializes all gateway commands onto that thread.
## 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/`.
## Types
| Type | Role |
|------|------|
| `StaRuntime` | Owns the STA `Thread`, the command queue, and the lifecycle gates. |
| `IStaComApartmentInitializer` / `StaComApartmentInitializer` | Calls `CoInitializeEx` / `CoUninitialize` so the thread enters and leaves the apartment-threaded apartment. |
| `StaMessagePump` | Wraps `MsgWaitForMultipleObjectsEx`, `PeekMessage`, `TranslateMessage`, and `DispatchMessage` so the STA loop can wait on work and drain the Windows message queue. |
| `IStaWorkItem` / `StaWorkItem<T>` | Internal queue entries that capture a delegate, a `CancellationToken`, and a `TaskCompletionSource<T>` for the caller. |
| `StaCommand` | Carries an `MxCommand` together with `SessionId`, `CorrelationId`, `EnqueueTimestamp`, and a `CancellationToken`. |
| `IStaCommandExecutor` | The boundary between the dispatcher and the MXAccess interop layer; returns `MxCommandReply`. |
| `StaCommandDispatcher` | Bounded asynchronous queue in front of `StaRuntime` that converts `StaCommand` into `MxCommandReply` and applies status normalization. |
## STA Thread Initialization
`StaRuntime`'s constructor configures a background `Thread` named `MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call:
```csharp
staThread = new Thread(ThreadMain)
{
IsBackground = true,
Name = "MxGateway.Worker.STA"
};
staThread.SetApartmentState(ApartmentState.STA);
```
`StaComApartmentInitializer.Initialize` calls `CoInitializeEx` with `COINIT_APARTMENTTHREADED` (`0x2`) and treats both `S_OK` and `S_FALSE` as success because `S_FALSE` indicates the apartment was already initialized on this thread. Any other HRESULT throws `COMException` so `ThreadMain` records the failure and signals `startedEvent`, which causes `Start()` to surface the exception.
## The STA Loop
`ThreadMain` runs the canonical "wait, pump, dispatch" loop. It enters COM, drains queued work, blocks until either a command arrives or a Windows message is posted, then pumps the message queue and records activity for the heartbeat:
```csharp
StaThreadId = Thread.CurrentThread.ManagedThreadId;
comApartmentInitializer.Initialize();
comInitialized = true;
MarkActivity();
startedEvent.Set();
while (!IsShutdownRequested())
{
ProcessQueuedCommands();
messagePump.WaitForWorkOrMessages(commandWakeEvent, idlePumpInterval);
messagePump.PumpPendingMessages();
MarkActivity();
}
```
`commandWakeEvent` is an `AutoResetEvent` set by `InvokeAsync` whenever a new work item is enqueued. The `idlePumpInterval` defaults to 50 ms so the pump still services Windows messages even when no commands are queued; this matters because COM event sink calls arrive as posted messages and would otherwise sit in the queue until the next command. `LastActivityUtc` is updated through `Volatile.Write` on every iteration so the worker's heartbeat path can read it without holding `gate`.
### The message pump
`StaMessagePump.WaitForWorkOrMessages` calls `MsgWaitForMultipleObjectsEx` with `QS_ALLINPUT` and `MWMO_INPUTAVAILABLE`, so the wait wakes for either a signal on `commandWakeEvent` or any new message in the thread's Windows queue. `PumpPendingMessages` then drains the queue with `PM_REMOVE`:
```csharp
public int PumpPendingMessages()
{
int pumpedMessages = 0;
while (PeekMessage(out NativeMessage message, IntPtr.Zero, 0, 0, PmRemove))
{
TranslateMessage(ref message);
DispatchMessage(ref message);
pumpedMessages++;
}
return pumpedMessages;
}
```
`DispatchMessage` is what causes the COM marshaler to deliver event sink calls onto the STA thread; without this loop, MXAccess events never surface to managed code.
## Work Items and the Command Queue
`StaRuntime` exposes two `InvokeAsync` overloads. Both wrap the delegate in an internal `StaWorkItem<T>`, enqueue it on a `ConcurrentQueue<IStaWorkItem>`, and signal `commandWakeEvent`:
```csharp
StaWorkItem<T> workItem = new(command, cancellationToken);
lock (gate)
{
if (shutdownRequested)
{
return Task.FromException<T>(
new InvalidOperationException("The worker STA runtime is shutting down."));
}
commandQueue.Enqueue(workItem);
}
commandWakeEvent.Set();
return workItem.Task;
```
`StaWorkItem<T>` uses an `Interlocked.CompareExchange` on `started` so that exactly one of three outcomes happens: the STA thread executes the delegate, an external `CancellationToken` cancellation fires first, or `CancelBeforeExecution` runs during shutdown. `Execute` runs on the STA thread, sets `Completion` from `command()`, and propagates exceptions through `TrySetException` so the awaiting caller observes them.
`ProcessQueuedCommands` is the only consumer of the queue and runs on the STA thread, so each work item is guaranteed to execute on the apartment that owns the COM object.
## Command Dispatch
`StaCommandDispatcher` sits between the worker's IPC layer and `StaRuntime`. It converts an `StaCommand` into an `MxCommandReply` and enforces a bounded queue: when `commandQueue.Count` reaches `maxPendingCommands` (default `DefaultMaxPendingCommands = 128`) the dispatcher returns a synthetic `WorkerUnavailable` reply rather than queueing further work. This back-pressure keeps the STA from accumulating an unbounded backlog while it is busy with a long-running call.
A single drain task pulls from `commandQueue` and submits each command to the STA via `staRuntime.InvokeAsync`:
```csharp
SetCurrentCommand(command.CorrelationId);
try
{
MxCommandReply reply = await staRuntime
.InvokeAsync(() => commandExecutor.Execute(command))
.ConfigureAwait(false);
queuedCommand.Complete(NormalizeReply(command, reply));
}
catch (Exception exception)
{
queuedCommand.Complete(CreateExceptionReply(command, exception));
}
finally
{
SetCurrentCommand(string.Empty);
}
```
`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."
## 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.
## Shutdown Sequence
`StaRuntime.Shutdown(TimeSpan timeout)` performs an ordered shutdown:
1. Sets `shutdownRequested` under `gate` so `InvokeAsync` rejects new work with `InvalidOperationException`.
2. Signals `commandWakeEvent` to break the STA out of `WaitForWorkOrMessages`.
3. Waits up to `timeout` on `stoppedEvent`, which the STA sets after it leaves `ThreadMain`.
4. Once the thread has stopped, drains the queue through `CancelQueuedCommands`, which calls `CancelBeforeExecution` on every remaining work item so awaiting callers observe `OperationCanceledException` instead of hanging.
`ThreadMain`'s `finally` block guarantees that `comApartmentInitializer.Uninitialize` runs (when COM was successfully initialized) before `stoppedEvent.Set`, so the apartment is always torn down on the same thread that initialized it. `Dispose` calls `Shutdown` with a five-second budget and only disposes the wait handles when shutdown actually completed, which prevents a still-running STA thread from touching disposed handles.
`StaCommandDispatcher.RequestShutdown` mirrors the same intent at the dispatcher layer: it sets `shutdownRequested`, drains its own queue, and completes every queued command with `ProtocolStatusCode.WorkerUnavailable` so callers receive a structured rejection during teardown.
## Related Documentation
- [MXAccess worker instance design](./mxaccess-worker-instance-design.md)
- [Worker conversion](./WorkerConversion.md)
- [Worker bootstrap](./WorkerBootstrap.md)
+5 -1
View File
@@ -23,11 +23,15 @@ The source files listed by the manifest are:
- `src/MxGateway.Contracts/Protos/mxaccess_gateway.proto`
- `src/MxGateway.Contracts/Protos/mxaccess_worker.proto`
- `src/MxGateway.Contracts/Protos/galaxy_repository.proto`
`mxaccess_gateway.proto` defines the public gRPC service and shared DTOs.
`mxaccess_worker.proto` is included in the descriptor because worker-aware
tests and fake-worker clients need the same command, reply, event, value, and
status shapes.
status shapes. `galaxy_repository.proto` defines the read-only Galaxy
Repository browse service used by clients to enumerate the deployed object
hierarchy and dynamic attributes; see
[Galaxy Repository Browse](./GalaxyRepository.md).
## Protocol Version
+10
View File
@@ -48,10 +48,20 @@ Endpoint layout:
/dashboard/sessions/{sessionId}
/dashboard/workers
/dashboard/events
/dashboard/galaxy
/dashboard/settings
/dashboard/_blazor
```
The `/dashboard/galaxy` page surfaces the Galaxy Repository browse summary
(deployed object hierarchy size, last deploy timestamp, attribute totals,
template usage, and connectivity sync info). The summary is fed by
`GalaxySummaryCache`, which is refreshed off the request path by
`GalaxySummaryRefreshService` on the
`MxGateway:Galaxy:DashboardRefreshIntervalSeconds` cadence so the dashboard
never blocks on SQL. See [Galaxy Repository Browse](./GalaxyRepository.md) for
the underlying gRPC service.
The app should redirect `/` to `/dashboard` only if the deployment wants the
dashboard as the default web page. Otherwise leave gRPC/API hosting unaffected.
+3
View File
@@ -62,6 +62,9 @@ Detailed follow-up docs:
Python, and Java.
- `docs/implementation-plan-index.md` links the detailed implementation plans
and recommended Gitea milestones/issues.
- `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse
RPCs that let clients enumerate the deployed object hierarchy and dynamic
attributes before subscribing via the MXAccess gateway service.
Implementation style guides:
+2 -1
View File
@@ -78,7 +78,8 @@ try {
"--include_source_info" `
"--descriptor_set_out=$outputPath" `
"mxaccess_gateway.proto" `
"mxaccess_worker.proto"
"mxaccess_worker.proto" `
"galaxy_repository.proto"
if ($LASTEXITCODE -ne 0) {
throw "protoc failed with exit code $LASTEXITCODE."
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
// <auto-generated>
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: galaxy_repository.proto
// </auto-generated>
#pragma warning disable 0414, 1591, 8981, 0612
#region Designer generated code
using grpc = global::Grpc.Core;
namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>
/// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
/// database). Lets clients enumerate the deployed object hierarchy and each
/// object's dynamic attributes so they know what tag references to subscribe
/// to via the MxAccessGateway service.
/// </summary>
public static partial class GalaxyRepository
{
static readonly string __ServiceName = "galaxy_repository.v1.GalaxyRepository";
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context)
{
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
if (message is global::Google.Protobuf.IBufferMessage)
{
context.SetPayloadLength(message.CalculateSize());
global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter());
context.Complete();
return;
}
#endif
context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static class __Helper_MessageCache<T>
{
public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static T __Helper_DeserializeMessage<T>(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser<T> parser) where T : global::Google.Protobuf.IMessage<T>
{
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
if (__Helper_MessageCache<T>.IsBufferMessage)
{
return parser.ParseFrom(context.PayloadAsReadOnlySequence());
}
#endif
return parser.ParseFrom(context.PayloadAsNewBuffer());
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest> __Marshaller_galaxy_repository_v1_TestConnectionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> __Marshaller_galaxy_repository_v1_TestConnectionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest> __Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> __Marshaller_galaxy_repository_v1_GetLastDeployTimeReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest> __Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> __Marshaller_galaxy_repository_v1_DiscoverHierarchyReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest> __Marshaller_galaxy_repository_v1_WatchDeployEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> __Marshaller_galaxy_repository_v1_DeployEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> __Method_TestConnection = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply>(
grpc::MethodType.Unary,
__ServiceName,
"TestConnection",
__Marshaller_galaxy_repository_v1_TestConnectionRequest,
__Marshaller_galaxy_repository_v1_TestConnectionReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> __Method_GetLastDeployTime = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply>(
grpc::MethodType.Unary,
__ServiceName,
"GetLastDeployTime",
__Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest,
__Marshaller_galaxy_repository_v1_GetLastDeployTimeReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> __Method_DiscoverHierarchy = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply>(
grpc::MethodType.Unary,
__ServiceName,
"DiscoverHierarchy",
__Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest,
__Marshaller_galaxy_repository_v1_DiscoverHierarchyReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> __Method_WatchDeployEvents = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent>(
grpc::MethodType.ServerStreaming,
__ServiceName,
"WatchDeployEvents",
__Marshaller_galaxy_repository_v1_WatchDeployEventsRequest,
__Marshaller_galaxy_repository_v1_DeployEvent);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.Services[0]; }
}
/// <summary>Base class for server-side implementations of GalaxyRepository</summary>
[grpc::BindServiceMethod(typeof(GalaxyRepository), "BindService")]
public abstract partial class GalaxyRepositoryBase
{
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request received from the client.</param>
/// <param name="responseStream">Used for sending responses back to the client.</param>
/// <param name="context">The context of the server-side call handler being invoked.</param>
/// <returns>A task indicating completion of the handler.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> responseStream, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for GalaxyRepository</summary>
public partial class GalaxyRepositoryClient : grpc::ClientBase<GalaxyRepositoryClient>
{
/// <summary>Creates a new client for GalaxyRepository</summary>
/// <param name="channel">The channel to use to make remote calls.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public GalaxyRepositoryClient(grpc::ChannelBase channel) : base(channel)
{
}
/// <summary>Creates a new client for GalaxyRepository that uses a custom <c>CallInvoker</c>.</summary>
/// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public GalaxyRepositoryClient(grpc::CallInvoker callInvoker) : base(callInvoker)
{
}
/// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected GalaxyRepositoryClient() : base()
{
}
/// <summary>Protected constructor to allow creation of configured clients.</summary>
/// <param name="configuration">The client configuration.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected GalaxyRepositoryClient(ClientBaseConfiguration configuration) : base(configuration)
{
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return TestConnection(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_TestConnection, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return TestConnectionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_TestConnection, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return GetLastDeployTime(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_GetLastDeployTime, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return GetLastDeployTimeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_GetLastDeployTime, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return DiscoverHierarchy(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_DiscoverHierarchy, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return DiscoverHierarchyAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_DiscoverHierarchy, null, options, request);
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request to send to the server.</param>
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
/// <param name="cancellationToken">An optional token for canceling the call.</param>
/// <returns>The call object.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return WatchDeployEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request to send to the server.</param>
/// <param name="options">The options for the call.</param>
/// <returns>The call object.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncServerStreamingCall(__Method_WatchDeployEvents, null, options, request);
}
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override GalaxyRepositoryClient NewInstance(ClientBaseConfiguration configuration)
{
return new GalaxyRepositoryClient(configuration);
}
}
/// <summary>Creates service definition that can be registered with a server</summary>
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public static grpc::ServerServiceDefinition BindService(GalaxyRepositoryBase serviceImpl)
{
return grpc::ServerServiceDefinition.CreateBuilder()
.AddMethod(__Method_TestConnection, serviceImpl.TestConnection)
.AddMethod(__Method_GetLastDeployTime, serviceImpl.GetLastDeployTime)
.AddMethod(__Method_DiscoverHierarchy, serviceImpl.DiscoverHierarchy)
.AddMethod(__Method_WatchDeployEvents, serviceImpl.WatchDeployEvents).Build();
}
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
/// Note: this method is part of an experimental API that can change or be removed without any prior notice.</summary>
/// <param name="serviceBinder">Service methods will be bound by calling <c>AddMethod</c> on this object.</param>
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public static void BindService(grpc::ServiceBinderBase serviceBinder, GalaxyRepositoryBase serviceImpl)
{
serviceBinder.AddMethod(__Method_TestConnection, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply>(serviceImpl.TestConnection));
serviceBinder.AddMethod(__Method_GetLastDeployTime, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply>(serviceImpl.GetLastDeployTime));
serviceBinder.AddMethod(__Method_DiscoverHierarchy, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply>(serviceImpl.DiscoverHierarchy));
serviceBinder.AddMethod(__Method_WatchDeployEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent>(serviceImpl.WatchDeployEvents));
}
}
}
#endregion
@@ -8,6 +8,7 @@
<Compile Remove="Generated\**\*.cs" />
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
<Protobuf Include="Protos\mxaccess_worker.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcServices="None" />
<Protobuf Include="Protos\galaxy_repository.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,89 @@
syntax = "proto3";
package galaxy_repository.v1;
option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy";
import "google/protobuf/timestamp.proto";
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
service GalaxyRepository {
rpc TestConnection(TestConnectionRequest) returns (TestConnectionReply);
rpc GetLastDeployTime(GetLastDeployTimeRequest) returns (GetLastDeployTimeReply);
rpc DiscoverHierarchy(DiscoverHierarchyRequest) returns (DiscoverHierarchyReply);
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
rpc WatchDeployEvents(WatchDeployEventsRequest) returns (stream DeployEvent);
}
message TestConnectionRequest {}
message TestConnectionReply {
bool ok = 1;
}
message GetLastDeployTimeRequest {}
message GetLastDeployTimeReply {
bool present = 1;
google.protobuf.Timestamp time_of_last_deploy = 2;
}
message DiscoverHierarchyRequest {}
message DiscoverHierarchyReply {
repeated GalaxyObject objects = 1;
}
message WatchDeployEventsRequest {
// Optional. When set, the bootstrap event is suppressed if the cached deploy
// time matches this value. Future events are still emitted normally.
google.protobuf.Timestamp last_seen_deploy_time = 1;
}
message DeployEvent {
// Monotonically increasing per server start. Gaps indicate dropped events.
uint64 sequence = 1;
// Server wall-clock when the cache observed the deploy.
google.protobuf.Timestamp observed_at = 2;
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
google.protobuf.Timestamp time_of_last_deploy = 3;
bool time_of_last_deploy_present = 4;
int32 object_count = 5;
int32 attribute_count = 6;
}
message GalaxyObject {
int32 gobject_id = 1;
string tag_name = 2;
string contained_name = 3;
string browse_name = 4;
int32 parent_gobject_id = 5;
bool is_area = 6;
int32 category_id = 7;
int32 hosted_by_gobject_id = 8;
repeated string template_chain = 9;
repeated GalaxyAttribute attributes = 10;
}
message GalaxyAttribute {
string attribute_name = 1;
string full_tag_reference = 2;
int32 mx_data_type = 3;
string data_type_name = 4;
bool is_array = 5;
int32 array_dimension = 6;
bool array_dimension_present = 7;
int32 mx_attribute_category = 8;
int32 security_classification = 9;
bool is_historized = 10;
bool is_alarm = 11;
}
@@ -0,0 +1,68 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests
{
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task TestConnection_AgainstZb_Succeeds()
{
GalaxyRepository repository = CreateRepository();
bool ok = await repository.TestConnectionAsync(CancellationToken.None);
Assert.True(ok, "TestConnectionAsync should return true against the ZB database.");
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
{
GalaxyRepository repository = CreateRepository();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync(CancellationToken.None);
Assert.NotNull(lastDeploy);
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
{
GalaxyRepository repository = CreateRepository();
List<GalaxyHierarchyRow> rows = await repository.GetHierarchyAsync(CancellationToken.None);
Assert.NotEmpty(rows);
Assert.All(rows, row =>
{
Assert.True(row.GobjectId > 0);
Assert.False(string.IsNullOrEmpty(row.TagName));
Assert.False(string.IsNullOrEmpty(row.BrowseName));
});
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{
GalaxyRepository repository = CreateRepository();
List<GalaxyAttributeRow> rows = await repository.GetAttributesAsync(CancellationToken.None);
Assert.NotEmpty(rows);
Assert.All(rows, row =>
{
Assert.True(row.GobjectId > 0);
Assert.False(string.IsNullOrEmpty(row.AttributeName));
Assert.False(string.IsNullOrEmpty(row.FullTagReference));
});
}
private static GalaxyRepository CreateRepository() => new(new GalaxyRepositoryOptions
{
ConnectionString = LiveGalaxyRepositoryFactAttribute.ConnectionString,
CommandTimeoutSeconds = 30,
});
}
@@ -0,0 +1,25 @@
namespace MxGateway.IntegrationTests.Galaxy;
public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
{
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS";
public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN";
public LiveGalaxyRepositoryFactAttribute()
{
if (!Enabled)
{
Skip = $"Set {EnableVariableName}=1 to run live Galaxy Repository tests.";
}
}
public static bool Enabled =>
string.Equals(
Environment.GetEnvironmentVariable(EnableVariableName),
"1",
StringComparison.Ordinal);
public static string ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariableName)
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
}
@@ -251,6 +251,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
_metrics,
_loggerFactory.CreateLogger<MxAccessGatewayService>());
}
@@ -23,6 +23,9 @@
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
@@ -29,6 +29,26 @@ else
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
</section>
<section class="dashboard-section">
<div class="section-heading d-flex align-items-center gap-2">
<h2>Galaxy Repository</h2>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
<NavLink class="ms-auto small" href="galaxy">View browse details &rarr;</NavLink>
</div>
<div class="metric-grid compact">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@GalaxyRefreshDetail()" />
</div>
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent Faults</h2>
@@ -36,3 +56,23 @@ else
<FaultList Faults="@Snapshot.Faults" />
</section>
}
@code {
private string? GalaxyRefreshDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
}
@@ -0,0 +1,195 @@
@page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading Galaxy summary.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Galaxy Repository</h1>
<div class="text-secondary">@RefreshHeading()</div>
</div>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
</div>
<section class="metric-grid">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" />
</section>
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
{
<section class="dashboard-section">
<div class="empty-state">
Galaxy summary has not been collected yet. The dashboard refreshes the
summary every @RefreshIntervalSeconds() seconds via the
<code>GalaxyRepository</code> service.
</div>
</section>
}
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<section class="dashboard-section">
<div class="section-heading">
<h2>Last Error</h2>
</div>
<div class="empty-state">@Snapshot.Galaxy.LastError</div>
</section>
}
<section class="dashboard-section">
<div class="section-heading">
<h2>Object Categories</h2>
</div>
@if (Snapshot.Galaxy.ObjectCategories.Count == 0)
{
<div class="empty-state">No deployed objects observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Category ID</th>
<th scope="col">Objects</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyCategoryCount row in Snapshot.Galaxy.ObjectCategories)
{
<tr>
<td>@row.CategoryName</td>
<td><code>@row.CategoryId</code></td>
<td>@DashboardDisplay.Count(row.ObjectCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Top Templates</h2>
</div>
@if (Snapshot.Galaxy.TopTemplates.Count == 0)
{
<div class="empty-state">No template usage observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Template</th>
<th scope="col">Instances</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyTemplateUsage row in Snapshot.Galaxy.TopTemplates)
{
<tr>
<td><code>@row.TemplateName</code></td>
<td>@DashboardDisplay.Count(row.InstanceCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Sync Info</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Status</th><td><StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" /></td></tr>
<tr><th scope="row">Last successful refresh</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)</td></tr>
<tr><th scope="row">Last attempt</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastQueriedAt)</td></tr>
<tr><th scope="row">Galaxy <code>time_of_last_deploy</code></th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)</td></tr>
<tr><th scope="row">Refresh interval</th><td>@RefreshIntervalSeconds() seconds</td></tr>
<tr><th scope="row">Connection string</th><td><code>@DashboardDisplay.Text(GalaxyConnectionStringDisplay())</code></td></tr>
<tr><th scope="row">Command timeout</th><td>@CommandTimeoutSeconds() seconds</td></tr>
</tbody>
</table>
</div>
<div class="text-secondary small mt-2">
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
<code>GetLastDeployTime</code> to detect redeployments.
</div>
</section>
}
@code {
[Inject]
private IOptions<MxGateway.Server.Galaxy.GalaxyRepositoryOptions> GalaxyOptions { get; set; } = null!;
private string RefreshHeading()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
return galaxy.LastSuccessAt is null
? "Awaiting first successful refresh"
: $"Refreshed {DashboardDisplay.DateTime(galaxy.LastSuccessAt)}";
}
private string? DeployAge()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastDeployTime is null || galaxy.LastSuccessAt is null)
{
return null;
}
TimeSpan age = galaxy.LastSuccessAt.Value - galaxy.LastDeployTime.Value;
if (age < TimeSpan.Zero)
{
return null;
}
return $"{DashboardDisplay.Duration(age)} ago";
}
private string? LastAttemptDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
private int RefreshIntervalSeconds() => Math.Max(1, GalaxyOptions.Value.DashboardRefreshIntervalSeconds);
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
private string? GalaxyConnectionStringDisplay() =>
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
}
@@ -9,7 +9,9 @@
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Faulted" => "text-bg-danger",
"Stale" => "text-bg-warning",
"Faulted" or "Unavailable" => "text-bg-danger",
"Unknown" => "text-bg-light text-dark border",
_ => "text-bg-light text-dark border"
};
}
@@ -0,0 +1,98 @@
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>
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",
};
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);
}
}
@@ -0,0 +1,47 @@
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard.
/// Populated by <see cref="GalaxySummaryCache"/> on a background refresh cadence so
/// the dashboard never blocks on SQL.
/// </summary>
public sealed record DashboardGalaxySummary(
DashboardGalaxyStatus Status,
DateTimeOffset? LastQueriedAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
int ObjectCount,
int AreaCount,
int AttributeCount,
int HistorizedAttributeCount,
int AlarmAttributeCount,
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
{
public static DashboardGalaxySummary Unknown { get; } = new(
DashboardGalaxyStatus.Unknown,
LastQueriedAt: null,
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0,
TopTemplates: Array.Empty<DashboardGalaxyTemplateUsage>(),
ObjectCategories: Array.Empty<DashboardGalaxyCategoryCount>());
}
public enum DashboardGalaxyStatus
{
Unknown = 0,
Healthy = 1,
Stale = 2,
Unavailable = 3,
}
public sealed record DashboardGalaxyTemplateUsage(string TemplateName, int InstanceCount);
public sealed record DashboardGalaxyCategoryCount(int CategoryId, string CategoryName, int ObjectCount);
@@ -12,4 +12,5 @@ public sealed record DashboardSnapshot(
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
EffectiveGatewayConfiguration Configuration);
EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -14,6 +15,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
@@ -24,12 +26,14 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IGalaxyHierarchyCache galaxyHierarchyCache,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
@@ -65,7 +69,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
Configuration: _configurationProvider.GetEffectiveConfiguration());
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
@@ -0,0 +1,17 @@
namespace MxGateway.Server.Galaxy;
public enum GalaxyCacheStatus
{
/// <summary>Cache has never completed a refresh.</summary>
Unknown = 0,
/// <summary>Cache holds data from a recent successful refresh.</summary>
Healthy = 1,
/// <summary>Cache holds data, but the most recent refresh attempt failed
/// or no successful refresh has happened within the staleness threshold.</summary>
Stale = 2,
/// <summary>Latest refresh failed and no prior data is available.</summary>
Unavailable = 3,
}
@@ -0,0 +1,14 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
/// subscribers (the streaming gRPC RPC).
/// </summary>
public sealed record GalaxyDeployEventInfo(
long Sequence,
DateTimeOffset ObservedAt,
DateTimeOffset? TimeOfLastDeploy,
int ObjectCount,
int AttributeCount);
@@ -0,0 +1,74 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each
/// subscriber gets a private bounded channel so a slow client cannot back-pressure
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
/// event is dropped — clients use the sequence field to detect gaps.
/// </summary>
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
{
private const int SubscriberQueueCapacity = 16;
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
private GalaxyDeployEventInfo? _latest;
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
public void Publish(GalaxyDeployEventInfo info)
{
ArgumentNullException.ThrowIfNull(info);
Volatile.Write(ref _latest, info);
foreach (Channel<GalaxyDeployEventInfo> channel in _subscribers.Values)
{
// BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the
// channel was completed by the subscriber side, which we ignore.
channel.Writer.TryWrite(info);
}
}
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
Guid subscriberId = Guid.NewGuid();
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
new BoundedChannelOptions(SubscriberQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
_subscribers[subscriberId] = channel;
// Bootstrap: emit the latest known event so subscribers don't need to wait for
// the next deploy to know current state.
GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest);
if (bootstrap is not null)
{
channel.Writer.TryWrite(bootstrap);
}
try
{
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
{
yield return next;
}
}
}
finally
{
_subscribers.TryRemove(subscriberId, out _);
channel.Writer.TryComplete();
}
}
}
@@ -0,0 +1,186 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Grpc;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
/// entry — the materialized <see cref="DiscoverHierarchyReply"/> is produced once per
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
/// attributes rowsets are pulled only when that timestamp has advanced.
/// </summary>
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
private readonly GalaxyRepository _repository;
private readonly IGalaxyDeployNotifier _notifier;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GalaxyHierarchyCache>? _logger;
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
public GalaxyHierarchyCache(
GalaxyRepository repository,
IGalaxyDeployNotifier notifier,
TimeProvider? timeProvider = null,
ILogger<GalaxyHierarchyCache>? logger = null)
{
_repository = repository;
_notifier = notifier;
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger;
}
public GalaxyHierarchyCacheEntry Current
{
get
{
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
GalaxyCacheStatus projected = ProjectStatus(snapshot);
return projected == snapshot.Status ? snapshot : snapshot with { Status = projected };
}
}
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_refreshGate.Release();
}
}
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
{
return _firstLoad.Task.WaitAsync(cancellationToken);
}
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
{
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
try
{
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
DateTimeOffset? deployTime = deployRaw.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
: null;
bool hasPriorData = previous.HasData;
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
if (!deployChanged)
{
// No deploy change — skip heavy queries; just bump LastSuccessAt.
GalaxyHierarchyCacheEntry refreshed = previous with
{
Status = GalaxyCacheStatus.Healthy,
LastQueriedAt = queriedAt,
LastSuccessAt = queriedAt,
LastError = null,
};
Volatile.Write(ref _current, refreshed);
_firstLoad.TrySetResult();
return;
}
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result;
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
int areaCount = hierarchy.Count(row => row.IsArea);
int historized = attributes.Count(row => row.IsHistorized);
int alarms = attributes.Count(row => row.IsAlarm);
long nextSequence = previous.Sequence + 1;
GalaxyHierarchyCacheEntry next = new(
Status: GalaxyCacheStatus.Healthy,
Sequence: nextSequence,
LastQueriedAt: queriedAt,
LastSuccessAt: queriedAt,
LastDeployTime: deployTime,
LastError: null,
Hierarchy: hierarchy,
Attributes: attributes,
Reply: reply,
ObjectCount: hierarchy.Count,
AreaCount: areaCount,
AttributeCount: attributes.Count,
HistorizedAttributeCount: historized,
AlarmAttributeCount: alarms);
Volatile.Write(ref _current, next);
_firstLoad.TrySetResult();
_notifier.Publish(new GalaxyDeployEventInfo(
Sequence: nextSequence,
ObservedAt: queriedAt,
TimeOfLastDeploy: deployTime,
ObjectCount: hierarchy.Count,
AttributeCount: attributes.Count));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception) when (exception is SqlException or InvalidOperationException)
{
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
GalaxyHierarchyCacheEntry failed = previous with
{
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
LastQueriedAt = queriedAt,
LastError = exception.Message,
};
Volatile.Write(ref _current, failed);
_firstLoad.TrySetResult();
}
}
private static DiscoverHierarchyReply BuildReply(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
{
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
DiscoverHierarchyReply reply = new();
foreach (GalaxyHierarchyRow row in hierarchy)
{
reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
}
return reply;
}
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
{
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
{
return snapshot.Status;
}
if (snapshot.LastSuccessAt is { } success
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
{
return GalaxyCacheStatus.Stale;
}
return snapshot.Status;
}
}
@@ -0,0 +1,43 @@
using MxGateway.Contracts.Proto.Galaxy;
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.
/// </summary>
public sealed record GalaxyHierarchyCacheEntry(
GalaxyCacheStatus Status,
long Sequence,
DateTimeOffset? LastQueriedAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
IReadOnlyList<GalaxyAttributeRow> Attributes,
DiscoverHierarchyReply? Reply,
int ObjectCount,
int AreaCount,
int AttributeCount,
int HistorizedAttributeCount,
int AlarmAttributeCount)
{
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
Status: GalaxyCacheStatus.Unknown,
Sequence: 0,
LastQueriedAt: null,
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
Hierarchy: Array.Empty<GalaxyHierarchyRow>(),
Attributes: Array.Empty<GalaxyAttributeRow>(),
Reply: null,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0);
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
}
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Periodically refreshes <see cref="IGalaxyHierarchyCache"/> off the request path. The
/// interval comes from <see cref="GalaxyRepositoryOptions.DashboardRefreshIntervalSeconds"/>;
/// each tick is cheap when the deploy timestamp is unchanged.
/// </summary>
public sealed class GalaxyHierarchyRefreshService(
IGalaxyHierarchyCache cache,
IOptions<GalaxyRepositoryOptions> options,
ILogger<GalaxyHierarchyRefreshService> logger,
TimeProvider? timeProvider = null) : BackgroundService
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
try
{
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
using PeriodicTimer timer = new(interval, _timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed.");
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,35 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
/// </summary>
public sealed class GalaxyHierarchyRow
{
public int GobjectId { get; init; }
public string TagName { get; init; } = string.Empty;
public string ContainedName { get; init; } = string.Empty;
public string BrowseName { get; init; } = string.Empty;
public int ParentGobjectId { get; init; }
public bool IsArea { get; init; }
public int CategoryId { get; init; }
public int HostedByGobjectId { get; init; }
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
}
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
public sealed class GalaxyAttributeRow
{
public int GobjectId { get; init; }
public string TagName { get; init; } = string.Empty;
public string AttributeName { get; init; } = string.Empty;
public string FullTagReference { get; init; } = string.Empty;
public int MxDataType { get; init; }
public string? DataTypeName { get; init; }
public bool IsArray { get; init; }
public int? ArrayDimension { get; init; }
public int MxAttributeCategory { get; init; }
public int SecurityClassification { get; init; }
public bool IsHistorized { get; init; }
public bool IsAlarm { get; init; }
}
@@ -0,0 +1,218 @@
using Microsoft.Data.SqlClient;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. Ported from
/// the OtOpcUa project so the row sets stay byte-for-byte identical between the two
/// consumers — the same SQL drives the OPC UA server's address space and this gateway's
/// gRPC browse surface.
/// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
{
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
{
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is int i && i == 1;
}
catch (SqlException) { return false; }
catch (InvalidOperationException) { return false; }
}
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
{ CommandTimeout = options.CommandTimeoutSeconds };
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is DateTime dt ? dt : null;
}
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
List<GalaxyHierarchyRow> rows = new();
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
string[] templateChain = templateChainRaw.Length == 0
? Array.Empty<string>()
: templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToArray();
rows.Add(new GalaxyHierarchyRow
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
BrowseName = reader.GetString(3),
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
CategoryId = Convert.ToInt32(reader.GetValue(6)),
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
TemplateChain = templateChain,
});
}
return rows;
}
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
List<GalaxyAttributeRow> rows = new();
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
rows.Add(new GalaxyAttributeRow
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
AttributeName = reader.GetString(2),
FullTagReference = reader.GetString(3),
MxDataType = Convert.ToInt32(reader.GetValue(4)),
DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
});
}
return rows;
}
private const string HierarchySql = @"
;WITH template_chain AS (
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
FROM gobject g
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
UNION ALL
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
FROM template_chain tc
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
)
SELECT DISTINCT
g.gobject_id,
g.tag_name,
g.contained_name,
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
THEN g.tag_name
ELSE g.contained_name
END AS browse_name,
CASE WHEN g.contained_by_gobject_id = 0
THEN g.area_gobject_id
ELSE g.contained_by_gobject_id
END AS parent_gobject_id,
CASE WHEN td.category_id = 13
THEN 1
ELSE 0
END AS is_area,
td.category_id AS category_id,
g.hosted_by_gobject_id AS hosted_by_gobject_id,
ISNULL(
STUFF((
SELECT '|' + tc.template_tag_name
FROM template_chain tc
WHERE tc.instance_gobject_id = g.gobject_id
ORDER BY tc.depth
FOR XML PATH('')
), 1, 1, ''),
''
) AS template_chain
FROM gobject g
INNER JOIN template_definition td
ON g.template_definition_id = td.template_definition_id
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND g.is_template = 0
AND g.deployed_package_id <> 0
ORDER BY parent_gobject_id, g.tag_name";
private const string AttributesSql = @"
;WITH deployed_package_chain AS (
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
FROM gobject g
INNER JOIN package p ON p.package_id = g.deployed_package_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
UNION ALL
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
)
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
mx_data_type, data_type_name, is_array, array_dimension,
mx_attribute_category, security_classification, is_historized, is_alarm
FROM (
SELECT
dpc.gobject_id,
g.tag_name,
da.attribute_name,
g.tag_name + '.' + da.attribute_name
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
AS full_tag_reference,
da.mx_data_type,
dt.description AS data_type_name,
da.is_array,
CASE WHEN da.is_array = 1
THEN CONVERT(int, CONVERT(varbinary(2),
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
ELSE NULL
END AS array_dimension,
da.mx_attribute_category,
da.security_classification,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_historized,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_alarm,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dynamic_attribute da
ON da.package_id = dpc.package_id
INNER JOIN gobject g
ON g.gobject_id = dpc.gobject_id
INNER JOIN template_definition td
ON td.template_definition_id = g.template_definition_id
LEFT JOIN data_type dt
ON dt.mx_data_type = da.mx_data_type
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND da.attribute_name NOT LIKE '[_]%'
AND da.attribute_name NOT LIKE '%.Description'
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
) ranked
WHERE rn = 1
ORDER BY tag_name, attribute_name";
}
@@ -0,0 +1,21 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database.
/// Bound to the <c>MxGateway:Galaxy</c> configuration section.
/// </summary>
public sealed class GalaxyRepositoryOptions
{
public const string SectionName = "MxGateway:Galaxy";
public string ConnectionString { get; init; } =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
public int CommandTimeoutSeconds { get; init; } = 60;
/// <summary>
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
/// </summary>
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
}
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy;
public static class GalaxyRepositoryServiceCollectionExtensions
{
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
{
services
.AddOptions<GalaxyRepositoryOptions>()
.BindConfiguration(GalaxyRepositoryOptions.SectionName)
.ValidateOnStart();
services.AddSingleton(sp =>
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
services.AddHostedService<GalaxyHierarchyRefreshService>();
return services;
}
}
@@ -0,0 +1,18 @@
namespace MxGateway.Server.Galaxy;
public interface IGalaxyDeployNotifier
{
/// <summary>The most recently published event, or <c>null</c> if no event has fired yet.</summary>
GalaxyDeployEventInfo? Latest { get; }
/// <summary>Publishes a deploy event to all current subscribers and stores it as <see cref="Latest"/>.</summary>
void Publish(GalaxyDeployEventInfo info);
/// <summary>
/// Subscribe to deploy events. The async sequence yields events as they fire. If
/// <see cref="Latest"/> is set, it is yielded first so subscribers can bootstrap their
/// local cache without waiting for the next deploy. Pass a cancellation token to
/// unsubscribe.
/// </summary>
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
namespace MxGateway.Server.Galaxy;
public interface IGalaxyHierarchyCache
{
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
GalaxyHierarchyCacheEntry Current { get; }
/// <summary>
/// Forces a refresh against the Galaxy Repository. Performs a cheap
/// <c>time_of_last_deploy</c> probe first and only re-queries the heavy hierarchy +
/// attributes rowsets when the deploy time has changed since the last successful
/// refresh.
/// </summary>
Task RefreshAsync(CancellationToken cancellationToken);
/// <summary>
/// Awaits the first completed refresh attempt (success or failure). Useful for
/// gRPC handlers that want to serve from cache without returning Unavailable on the
/// very first request after gateway start.
/// </summary>
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
}
@@ -3,6 +3,7 @@ using MxGateway.Contracts;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Diagnostics;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
@@ -51,6 +52,7 @@ public static class GatewayApplication
builder.Services.AddWorkerProcessLauncher();
builder.Services.AddGatewaySessions();
builder.Services.AddGatewayDashboard();
builder.Services.AddGalaxyRepository();
return builder;
}
@@ -125,6 +127,7 @@ public static class GatewayApplication
.WithName("LiveHealth");
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
endpoints.MapGatewayDashboard();
return endpoints;
@@ -0,0 +1,69 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Grpc;
/// <summary>
/// Maps <see cref="GalaxyHierarchyRow"/> + <see cref="GalaxyAttributeRow"/> rows produced
/// by <see cref="GalaxyRepository"/> into <c>galaxy_repository.v1</c> proto messages.
/// Pure function, separated so it can be unit-tested without a SQL connection.
/// </summary>
public static class GalaxyProtoMapper
{
public static IEnumerable<GalaxyObject> MapHierarchy(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
{
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (GalaxyHierarchyRow row in hierarchy)
{
yield return MapObject(row, attributesByGobjectId);
}
}
public static GalaxyObject MapObject(
GalaxyHierarchyRow row,
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
{
GalaxyObject obj = new()
{
GobjectId = row.GobjectId,
TagName = row.TagName,
ContainedName = row.ContainedName,
BrowseName = row.BrowseName,
ParentGobjectId = row.ParentGobjectId,
IsArea = row.IsArea,
CategoryId = row.CategoryId,
HostedByGobjectId = row.HostedByGobjectId,
};
obj.TemplateChain.AddRange(row.TemplateChain);
if (attributesByGobjectId.TryGetValue(row.GobjectId, out List<GalaxyAttributeRow>? attrs))
{
foreach (GalaxyAttributeRow attr in attrs)
{
obj.Attributes.Add(MapAttribute(attr));
}
}
return obj;
}
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
{
AttributeName = row.AttributeName,
FullTagReference = row.FullTagReference,
MxDataType = row.MxDataType,
DataTypeName = row.DataTypeName ?? string.Empty,
IsArray = row.IsArray,
ArrayDimension = row.ArrayDimension ?? 0,
ArrayDimensionPresent = row.ArrayDimension.HasValue,
MxAttributeCategory = row.MxAttributeCategory,
SecurityClassification = row.SecurityClassification,
IsHistorized = row.IsHistorized,
IsAlarm = row.IsAlarm,
};
}
@@ -0,0 +1,158 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Data.SqlClient;
using MxGateway.Contracts.Proto.Galaxy;
using GalaxyDb = MxGateway.Server.Galaxy;
using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
namespace MxGateway.Server.Grpc;
/// <summary>
/// gRPC surface that exposes the Galaxy Repository to clients. <c>DiscoverHierarchy</c>
/// and <c>GetLastDeployTime</c> serve from <see cref="GalaxyDb.IGalaxyHierarchyCache"/>
/// so many clients share a single SQL pull. <c>WatchDeployEvents</c> streams events
/// from <see cref="GalaxyDb.IGalaxyDeployNotifier"/>. <c>TestConnection</c> remains a
/// direct SQL probe since callers use it as a health check.
/// </summary>
public sealed class GalaxyRepositoryGrpcService(
GalaxyDb.GalaxyRepository repository,
GalaxyDb.IGalaxyHierarchyCache cache,
GalaxyDb.IGalaxyDeployNotifier notifier,
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
{
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
public override async Task<TestConnectionReply> TestConnection(
TestConnectionRequest request,
ServerCallContext context)
{
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
return new TestConnectionReply { Ok = ok };
}
public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
GetLastDeployTimeRequest request,
ServerCallContext context)
{
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData)
{
throw new RpcException(new Status(
StatusCode.Unavailable,
ResolveUnavailableMessage(entry)));
}
GetLastDeployTimeReply reply = new() { Present = entry.LastDeployTime.HasValue };
if (entry.LastDeployTime.HasValue)
{
reply.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(entry.LastDeployTime.Value);
}
return reply;
}
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
DiscoverHierarchyRequest request,
ServerCallContext context)
{
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData || entry.Reply is null)
{
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;
}
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
IServerStreamWriter<DeployEvent> responseStream,
ServerCallContext context)
{
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
await foreach (GalaxyDb.GalaxyDeployEventInfo info in notifier
.SubscribeAsync(context.CancellationToken)
.ConfigureAwait(false))
{
// Suppress the initial bootstrap event when the client already knows about
// this deploy time. We only suppress the first one — subsequent events fire
// on actual changes, so they always pass.
if (lastSeen is { } seen && info.TimeOfLastDeploy == seen)
{
lastSeen = null;
continue;
}
lastSeen = null;
await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false);
}
}
private async Task WaitForCacheBootstrap(CancellationToken cancellationToken)
{
if (cache.Current.HasData || cache.Current.Status == GalaxyDb.GalaxyCacheStatus.Unavailable)
{
return;
}
using CancellationTokenSource budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(FirstLoadWaitBudget);
try
{
await cache.WaitForFirstLoadAsync(budget.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
// Budget elapsed; fall through and let the caller see the current
// (possibly Unknown/Unavailable) entry.
}
}
private static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info)
{
DeployEvent ev = new()
{
Sequence = (ulong)info.Sequence,
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
ObjectCount = info.ObjectCount,
AttributeCount = info.AttributeCount,
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
};
if (info.TimeOfLastDeploy.HasValue)
{
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(info.TimeOfLastDeploy.Value);
}
return ev;
}
private static string ResolveUnavailableMessage(GalaxyDb.GalaxyHierarchyCacheEntry entry) => entry.Status switch
{
GalaxyDb.GalaxyCacheStatus.Unknown => "Galaxy cache has not completed its initial load yet.",
GalaxyDb.GalaxyCacheStatus.Unavailable => "Galaxy repository is unavailable.",
_ => "Galaxy cache has no data available.",
};
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Style",
"IDE0051:Remove unused private members",
Justification = "Kept for parity with prior SQL exception mapping; future direct-SQL paths reuse it.")]
private RpcException MapSqlException(SqlException exception)
{
logger.LogWarning(exception, "Galaxy repository query failed.");
return new RpcException(new Status(
StatusCode.Unavailable,
"Galaxy repository is unavailable."));
}
}
@@ -1,6 +1,8 @@
using System.Diagnostics;
using Grpc.Core;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -13,6 +15,7 @@ public sealed class MxAccessGatewayService(
MxAccessGrpcRequestValidator requestValidator,
MxAccessGrpcMapper mapper,
IEventStreamService eventStreamService,
GatewayMetrics metrics,
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
{
public override async Task<OpenSessionReply> OpenSession(
@@ -110,7 +113,9 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
Stopwatch stopwatch = Stopwatch.StartNew();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
}
}
catch (Exception exception) when (exception is not RpcException)
@@ -219,19 +219,6 @@ public sealed class GatewayMetrics : IDisposable
}
}
public void SetGrpcEventStreamQueueDepth(int depth)
{
if (depth < 0)
{
throw new ArgumentOutOfRangeException(nameof(depth), depth, "Queue depth cannot be negative.");
}
lock (_syncRoot)
{
_grpcEventStreamQueueDepth = depth;
}
}
public void AdjustGrpcEventStreamQueueDepth(int delta)
{
lock (_syncRoot)
@@ -7,6 +7,7 @@
<ItemGroup>
<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="Polly.Core" Version="8.6.6" />
</ItemGroup>
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Security.Authorization;
@@ -12,6 +13,10 @@ public sealed class GatewayGrpcScopeResolver
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
_ => GatewayScopes.Admin
};
}
+5
View File
@@ -43,6 +43,11 @@
},
"Protocol": {
"WorkerProtocolVersion": 1
},
"Galaxy": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
"CommandTimeoutSeconds": 60,
"DashboardRefreshIntervalSeconds": 30
}
}
}
@@ -0,0 +1,90 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyDeployNotifierTests
{
[Fact]
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
ValueTask<bool> moveNext = enumerator.MoveNextAsync();
Assert.False(moveNext.IsCompleted);
GalaxyDeployEventInfo published = new(
Sequence: 1,
ObservedAt: DateTimeOffset.UtcNow,
TimeOfLastDeploy: DateTimeOffset.UtcNow,
ObjectCount: 5,
AttributeCount: 25);
notifier.Publish(published);
Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(published, enumerator.Current);
await cts.CancelAsync();
await enumerator.DisposeAsync();
}
[Fact]
public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately()
{
GalaxyDeployNotifier notifier = new();
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9);
notifier.Publish(first);
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(first, enumerator.Current);
await cts.CancelAsync();
}
[Fact]
public async Task Publish_FansOutToAllSubscribers()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> a = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
await using IAsyncEnumerator<GalaxyDeployEventInfo> b = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(info);
Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(info, a.Current);
Assert.Same(info, b.Current);
await cts.CancelAsync();
}
[Fact]
public void Latest_TracksMostRecentPublish()
{
GalaxyDeployNotifier notifier = new();
Assert.Null(notifier.Latest);
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0);
GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(first);
notifier.Publish(second);
Assert.Same(second, notifier.Latest);
}
}
@@ -0,0 +1,74 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyHierarchyCacheTests
{
[Fact]
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
GalaxyDeployNotifier notifier = new();
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
GalaxyHierarchyCacheEntry entry = cache.Current;
Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
Assert.False(entry.HasData);
Assert.Equal(0, entry.ObjectCount);
Assert.Null(entry.Reply);
}
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
{
GalaxyDeployNotifier notifier = new();
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
await cache.RefreshAsync(CancellationToken.None);
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
Assert.Null(notifier.Latest);
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
}
[Fact]
public void HasData_OnHealthyEntry_IsTrue()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
LastSuccessAt = DateTimeOffset.UtcNow,
ObjectCount = 1,
};
Assert.True(entry.HasData);
}
[Fact]
public void HasData_OnUnknownEntry_IsFalse()
{
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
}
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
{
private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now += duration;
}
}
@@ -0,0 +1,109 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyProtoMapperTests
{
[Fact]
public void MapAttribute_PreservesAllScalarFields()
{
GalaxyAttributeRow row = new()
{
GobjectId = 42,
TagName = "Pump_001",
AttributeName = "Speed",
FullTagReference = "Pump_001.Speed",
MxDataType = 3,
DataTypeName = "Float",
IsArray = false,
ArrayDimension = null,
MxAttributeCategory = 5,
SecurityClassification = 2,
IsHistorized = true,
IsAlarm = false,
};
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal("Speed", proto.AttributeName);
Assert.Equal("Pump_001.Speed", proto.FullTagReference);
Assert.Equal(3, proto.MxDataType);
Assert.Equal("Float", proto.DataTypeName);
Assert.False(proto.IsArray);
Assert.Equal(0, proto.ArrayDimension);
Assert.False(proto.ArrayDimensionPresent);
Assert.Equal(5, proto.MxAttributeCategory);
Assert.Equal(2, proto.SecurityClassification);
Assert.True(proto.IsHistorized);
Assert.False(proto.IsAlarm);
}
[Fact]
public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero()
{
GalaxyAttributeRow withDim = new() { ArrayDimension = 0, IsArray = true };
GalaxyAttributeRow withoutDim = new() { ArrayDimension = null, IsArray = false };
Assert.True(GalaxyProtoMapper.MapAttribute(withDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withDim).ArrayDimension);
Assert.False(GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension);
}
[Fact]
public void MapAttribute_NullDataTypeName_BecomesEmptyString()
{
GalaxyAttributeRow row = new() { DataTypeName = null };
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal(string.Empty, proto.DataTypeName);
}
[Fact]
public void MapHierarchy_GroupsAttributesByGobjectId()
{
List<GalaxyHierarchyRow> hierarchy =
[
new() { GobjectId = 1, TagName = "A", BrowseName = "A", TemplateChain = ["RootTpl"] },
new() { GobjectId = 2, TagName = "B", BrowseName = "B", ParentGobjectId = 1 },
new() { GobjectId = 3, TagName = "C", BrowseName = "C", ParentGobjectId = 1 },
];
List<GalaxyAttributeRow> attributes =
[
new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X" },
new() { GobjectId = 2, AttributeName = "Y1", FullTagReference = "B.Y1" },
new() { GobjectId = 2, AttributeName = "Y2", FullTagReference = "B.Y2" },
];
List<GalaxyObject> result = GalaxyProtoMapper.MapHierarchy(hierarchy, attributes).ToList();
Assert.Equal(3, result.Count);
Assert.Single(result[0].Attributes);
Assert.Equal("X", result[0].Attributes[0].AttributeName);
Assert.Equal(2, result[1].Attributes.Count);
Assert.Empty(result[2].Attributes);
}
[Fact]
public void MapObject_CopiesTemplateChain()
{
GalaxyHierarchyRow row = new()
{
GobjectId = 5,
TagName = "Engine_001",
ContainedName = "Engine",
BrowseName = "Engine",
TemplateChain = ["EngineTpl", "AppEngineBase"],
};
GalaxyObject proto = GalaxyProtoMapper.MapObject(
row,
new Dictionary<int, List<GalaxyAttributeRow>>());
Assert.Equal(new[] { "EngineTpl", "AppEngineBase" }, proto.TemplateChain);
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -171,6 +172,51 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
}
[Fact]
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
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 },
],
ObjectCount = 3,
AreaCount = 1,
AttributeCount = 2,
HistorizedAttributeCount = 1,
AlarmAttributeCount = 1,
};
using GatewayMetrics metrics = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry));
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status);
Assert.Equal(3, snapshot.Galaxy.ObjectCount);
Assert.Equal(1, snapshot.Galaxy.AreaCount);
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -200,7 +246,8 @@ public sealed class DashboardSnapshotServiceTests
private static DashboardSnapshotService CreateService(
SessionRegistry registry,
GatewayMetrics metrics,
GatewayOptions? options = null)
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -215,9 +262,19 @@ public sealed class DashboardSnapshotServiceTests
registry,
metrics,
configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
Options.Create(resolvedOptions));
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private static GatewaySession CreateSession(
string sessionId,
string? clientIdentity,
@@ -174,6 +174,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
_metrics,
NullLogger<MxAccessGatewayService>.Instance);
}

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