Add Galaxy repository API and clients
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
+194
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+288
@@ -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;
|
||||
}
|
||||
}
|
||||
+327
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+641
@@ -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;
|
||||
}
|
||||
}
|
||||
+8077
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Generated
+12
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()],
|
||||
)?;
|
||||
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 →</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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user