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
|
||||
|
||||
Reference in New Issue
Block a user