Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
@@ -19,4 +20,20 @@ public interface IMxGatewayCliClient : IAsyncDisposable
IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken);
Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken);
Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken);
Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken);
IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken);
}
@@ -1,40 +1,84 @@
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
internal sealed class MxGatewayCliClientAdapter(MxGatewayClient client) : IMxGatewayCliClient
internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
{
private readonly MxGatewayClient _client;
private readonly Lazy<GalaxyRepositoryClient> _galaxyClient;
public MxGatewayCliClientAdapter(MxGatewayClient client)
{
_client = client;
_galaxyClient = new Lazy<GalaxyRepositoryClient>(
() => GalaxyRepositoryClient.Create(_client.Options));
}
public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request,
CancellationToken cancellationToken)
{
return client.OpenSessionRawAsync(request, cancellationToken);
return _client.OpenSessionRawAsync(request, cancellationToken);
}
public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request,
CancellationToken cancellationToken)
{
return client.CloseSessionRawAsync(request, cancellationToken);
return _client.CloseSessionRawAsync(request, cancellationToken);
}
public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request,
CancellationToken cancellationToken)
{
return client.InvokeAsync(request, cancellationToken);
return _client.InvokeAsync(request, cancellationToken);
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
CancellationToken cancellationToken)
{
return client.StreamEventsAsync(request, cancellationToken);
return _client.StreamEventsAsync(request, cancellationToken);
}
public ValueTask DisposeAsync()
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
return client.DisposeAsync();
return _galaxyClient.Value.TestConnectionRawAsync(request, cancellationToken);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.GetLastDeployTimeRawAsync(request, cancellationToken);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.DiscoverHierarchyRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken)
{
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
}
public async ValueTask DisposeAsync()
{
if (_galaxyClient.IsValueCreated)
{
await _galaxyClient.Value.DisposeAsync().ConfigureAwait(false);
}
await _client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -3,6 +3,7 @@ using System.Text.Json;
using Google.Protobuf;
using MxGateway.Client;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Cli;
@@ -70,7 +71,7 @@ public static class MxGatewayClientCli
}
await using IMxGatewayCliClient client = clientFactory(CreateOptions(arguments));
using CancellationTokenSource cancellation = CreateCancellation(arguments);
using CancellationTokenSource cancellation = CreateCancellation(arguments, command);
return command switch
{
@@ -98,6 +99,14 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"smoke" => await SmokeAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-test-connection" => await GalaxyTestConnectionAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-last-deploy" => await GalaxyLastDeployAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-discover" => await GalaxyDiscoverAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"galaxy-watch" => await GalaxyWatchAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
_ => WriteUnknownCommand(command, standardError),
};
}
@@ -168,9 +177,18 @@ public static class MxGatewayClientCli
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
}
private static CancellationTokenSource CreateCancellation(CliArguments arguments)
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
// Long-running streaming commands run until Ctrl+C / cancellation by default;
// a caller-supplied --timeout still applies if present.
bool isLongRunning = command is "galaxy-watch";
string? rawTimeout = arguments.GetOptional("timeout");
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
{
return cancellation;
}
TimeSpan timeout = arguments.GetDuration("timeout", TimeSpan.FromSeconds(30));
cancellation.CancelAfter(timeout);
return cancellation;
@@ -766,6 +784,146 @@ public static class MxGatewayClientCli
.ToMxValue();
}
private static Task<int> GalaxyTestConnectionAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return WriteReplyAsync(
client.GalaxyTestConnectionAsync(new TestConnectionRequest(), cancellationToken),
arguments,
output);
}
private static Task<int> GalaxyLastDeployAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
return WriteReplyAsync(
client.GalaxyGetLastDeployTimeAsync(new GetLastDeployTimeRequest(), cancellationToken),
arguments,
output);
}
private static async Task<int> GalaxyDiscoverAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
DiscoverHierarchyReply reply = await client.GalaxyDiscoverHierarchyAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
if (arguments.HasFlag("json"))
{
output.WriteLine(ProtobufJsonFormatter.Format(reply));
return 0;
}
output.WriteLine($"objects={reply.Objects.Count}");
foreach (GalaxyObject galaxyObject in reply.Objects)
{
output.WriteLine($"- gobject_id={galaxyObject.GobjectId} tag_name={galaxyObject.TagName} contained_name={galaxyObject.ContainedName} parent={galaxyObject.ParentGobjectId} attributes={galaxyObject.Attributes.Count}");
}
return 0;
}
private static async Task<int> GalaxyWatchAsync(
CliArguments arguments,
IMxGatewayCliClient client,
TextWriter output,
CancellationToken cancellationToken)
{
bool json = arguments.HasFlag("json");
uint maxEvents = arguments.GetUInt32("max-events", 0);
if (maxEvents > MaxAggregateEvents)
{
throw new ArgumentException($"--max-events cannot exceed {MaxAggregateEvents}.");
}
WatchDeployEventsRequest request = new();
string? lastSeen = arguments.GetOptional("last-seen-deploy-time");
if (!string.IsNullOrWhiteSpace(lastSeen))
{
DateTimeOffset parsed = DateTimeOffset.Parse(
lastSeen,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
request.LastSeenDeployTime = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(parsed);
}
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
ConsoleCancelEventHandler handler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
try
{
linked.Cancel();
}
catch (ObjectDisposedException)
{
}
};
Console.CancelKeyPress += handler;
uint emitted = 0;
try
{
await foreach (DeployEvent deployEvent in client
.GalaxyWatchDeployEventsAsync(request, linked.Token)
.WithCancellation(linked.Token)
.ConfigureAwait(false))
{
if (json)
{
output.WriteLine(ProtobufJsonFormatter.Format(deployEvent));
}
else
{
output.WriteLine(FormatDeployEvent(deployEvent));
}
emitted++;
if (maxEvents > 0 && emitted >= maxEvents)
{
break;
}
}
}
catch (OperationCanceledException) when (linked.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
// Ctrl+C-driven cancellation is a clean exit.
}
finally
{
Console.CancelKeyPress -= handler;
}
return 0;
}
private static string FormatDeployEvent(DeployEvent deployEvent)
{
string deployTime = deployEvent.TimeOfLastDeployPresent && deployEvent.TimeOfLastDeploy is not null
? deployEvent.TimeOfLastDeploy
.ToDateTimeOffset()
.ToString("O", CultureInfo.InvariantCulture)
: "<none>";
string observed = deployEvent.ObservedAt is not null
? deployEvent.ObservedAt
.ToDateTimeOffset()
.ToString("O", CultureInfo.InvariantCulture)
: "<unknown>";
return $"sequence={deployEvent.Sequence} observed_at={observed} time_of_last_deploy={deployTime} objects={deployEvent.ObjectCount} attributes={deployEvent.AttributeCount}";
}
private static int WriteUnknownCommand(string command, TextWriter standardError)
{
standardError.WriteLine($"Unknown command '{command}'.");
@@ -793,7 +951,11 @@ public static class MxGatewayClientCli
or "stream-events"
or "write"
or "write2"
or "smoke";
or "smoke"
or "galaxy-test-connection"
or "galaxy-last-deploy"
or "galaxy-discover"
or "galaxy-watch";
}
private static IReadOnlyList<string> ParseStringList(string value)
@@ -842,5 +1004,9 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet write --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--json]");
writer.WriteLine("mxgw-dotnet write2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet smoke --item <ref> [--value <value> --type <type>] [--json]");
writer.WriteLine("mxgw-dotnet galaxy-test-connection [--json]");
writer.WriteLine("mxgw-dotnet galaxy-last-deploy [--json]");
writer.WriteLine("mxgw-dotnet galaxy-discover [--json]");
writer.WriteLine("mxgw-dotnet galaxy-watch [--last-seen-deploy-time <iso8601>] [--max-events <n>] [--json]");
}
}
@@ -0,0 +1,103 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
public List<(TestConnectionRequest Request, CallOptions CallOptions)> TestConnectionCalls { get; } = [];
public List<(GetLastDeployTimeRequest Request, CallOptions CallOptions)> GetLastDeployTimeCalls { get; } = [];
public List<(DiscoverHierarchyRequest Request, CallOptions CallOptions)> DiscoverHierarchyCalls { get; } = [];
public TestConnectionReply TestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply DiscoverHierarchyReply { get; set; } = new();
public Queue<Exception> TestConnectionExceptions { get; } = new();
public Queue<Exception> GetLastDeployTimeExceptions { get; } = new();
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
TestConnectionCalls.Add((request, callOptions));
if (TestConnectionExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(TestConnectionReply);
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
GetLastDeployTimeCalls.Add((request, callOptions));
if (GetLastDeployTimeExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(GetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
DiscoverHierarchyCalls.Add((request, callOptions));
if (DiscoverHierarchyExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(DiscoverHierarchyReply);
}
public List<(WatchDeployEventsRequest Request, CallOptions CallOptions)> WatchDeployEventsCalls { get; } = [];
public List<DeployEvent> WatchDeployEvents { get; } = [];
public Exception? WatchDeployEventsException { get; set; }
/// <summary>
/// When set, awaited before each event yield so tests can observe cancellation
/// mid-stream. Receives the call's cancellation token.
/// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
WatchDeployEventsCalls.Add((request, callOptions));
if (WatchDeployEventsException is not null)
{
throw WatchDeployEventsException;
}
foreach (DeployEvent deployEvent in WatchDeployEvents)
{
if (WatchDeployEventsBeforeYield is not null)
{
await WatchDeployEventsBeforeYield(callOptions.CancellationToken).ConfigureAwait(false);
}
callOptions.CancellationToken.ThrowIfCancellationRequested();
yield return deployEvent;
}
}
}
@@ -0,0 +1,301 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
public sealed class GalaxyRepositoryClientTests
{
[Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync(cancellation.Token);
Assert.True(ok);
var call = Assert.Single(transport.TestConnectionCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
}
[Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionReply = new TestConnectionReply { Ok = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.False(ok);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply { Present = false };
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.Null(deployTime);
Assert.Single(transport.GetLastDeployTimeCalls);
}
[Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{
DateTime expected = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.GetLastDeployTimeReply = new GetLastDeployTimeReply
{
Present = true,
TimeOfLastDeploy = Timestamp.FromDateTime(expected),
};
await using GalaxyRepositoryClient client = CreateClient(transport);
DateTime? deployTime = await client.GetLastDeployTimeAsync();
Assert.NotNull(deployTime);
Assert.Equal(expected, deployTime!.Value);
}
[Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply
{
Objects =
{
new GalaxyObject
{
GobjectId = 12,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
BrowseName = "TestMachine_001/DelmiaReceiver",
ParentGobjectId = 5,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
MxDataType = 8,
DataTypeName = "MxString",
},
},
},
},
};
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<GalaxyObject> objects = await client.DiscoverHierarchyAsync();
GalaxyObject obj = Assert.Single(objects);
Assert.Equal(12, obj.GobjectId);
Assert.Equal("DelmiaReceiver_001", obj.TagName);
GalaxyAttribute attribute = Assert.Single(obj.Attributes);
Assert.Equal("DownloadPath", attribute.AttributeName);
Assert.Equal("DelmiaReceiver_001.DownloadPath", attribute.FullTagReference);
}
[Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{
using CancellationTokenSource cancellation = new();
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync(cancellation.Token);
var call = Assert.Single(transport.DiscoverHierarchyCalls);
// The retry pipeline links the caller token with a per-call timeout token,
// so the transport sees the linked token rather than the caller's directly.
// Verify the link relationship by cancelling the caller and checking the
// call-side token reflects it.
Assert.False(call.CallOptions.CancellationToken.IsCancellationRequested);
}
[Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.TestConnectionExceptions.Enqueue(CreateTransientRpcException());
transport.TestConnectionReply = new TestConnectionReply { Ok = true };
await using GalaxyRepositoryClient client = CreateClient(transport);
bool ok = await client.TestConnectionAsync();
Assert.True(ok);
Assert.Equal(2, transport.TestConnectionCalls.Count);
}
[Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.DiscoverHierarchyExceptions.Enqueue(CreateTransientRpcException());
transport.DiscoverHierarchyReply = new DiscoverHierarchyReply();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.DiscoverHierarchyAsync();
Assert.Equal(2, transport.DiscoverHierarchyCalls.Count);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime deployTime = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deployTime),
TimeOfLastDeploy = Timestamp.FromDateTime(deployTime),
TimeOfLastDeployPresent = true,
ObjectCount = 7,
AttributeCount = 42,
});
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync())
{
received.Add(evt);
}
DeployEvent only = Assert.Single(received);
Assert.Equal(1ul, only.Sequence);
Assert.Equal(7, only.ObjectCount);
Assert.Equal(42, only.AttributeCount);
Assert.True(only.TimeOfLastDeployPresent);
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.Equal("Bearer test-api-key", call.CallOptions.Headers?.GetValue("authorization"));
// No last_seen_deploy_time supplied → request leaves the field unset.
Assert.Null(call.Request.LastSeenDeployTime);
}
[Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
DateTime t0 = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
for (int index = 1; index <= 3; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent
{
Sequence = (ulong)index,
ObservedAt = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeploy = Timestamp.FromDateTime(t0.AddSeconds(index)),
TimeOfLastDeployPresent = true,
ObjectCount = 10 + index,
AttributeCount = 100 + index,
});
}
DateTimeOffset lastSeen = new(t0, TimeSpan.Zero);
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await foreach (DeployEvent evt in client.WatchDeployEventsAsync(lastSeen))
{
received.Add(evt);
}
Assert.Equal(3, received.Count);
Assert.Equal(new ulong[] { 1, 2, 3 }, received.Select(e => e.Sequence).ToArray());
Assert.Equal(new[] { 11, 12, 13 }, received.Select(e => e.ObjectCount).ToArray());
var call = Assert.Single(transport.WatchDeployEventsCalls);
Assert.NotNull(call.Request.LastSeenDeployTime);
Assert.Equal(t0, call.Request.LastSeenDeployTime!.ToDateTime());
}
[Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Add many events; the test will cancel after the first.
for (int index = 1; index <= 10; index++)
{
transport.WatchDeployEvents.Add(new DeployEvent { Sequence = (ulong)index });
}
using CancellationTokenSource cancellation = new();
// Cancel before the second yield by wiring the fake's pre-yield hook.
int yields = 0;
transport.WatchDeployEventsBeforeYield = _ =>
{
yields++;
if (yields >= 2)
{
cancellation.Cancel();
}
return Task.CompletedTask;
};
await using GalaxyRepositoryClient client = CreateClient(transport);
List<DeployEvent> received = [];
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (DeployEvent evt in client
.WatchDeployEventsAsync(cancellationToken: cancellation.Token))
{
received.Add(evt);
}
});
// The first event yields before cancellation triggers on the second pass.
Assert.Single(received);
Assert.Equal(1ul, received[0].Sequence);
}
[Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
Assert.Throws<ObjectDisposedException>(() =>
client.WatchDeployEventsAsync());
}
[Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
GalaxyRepositoryClient client = CreateClient(transport);
await client.DisposeAsync();
await Assert.ThrowsAsync<ObjectDisposedException>(() => client.TestConnectionAsync());
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
{
return new GalaxyRepositoryClient(transport.Options, transport);
}
private static FakeGalaxyRepositoryTransport CreateTransport()
{
return new FakeGalaxyRepositoryTransport(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
private static RpcException CreateTransientRpcException()
{
return new RpcException(new Status(StatusCode.Unavailable, "gateway unavailable"));
}
}
@@ -1,5 +1,7 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Client.Cli;
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client.Tests;
@@ -170,6 +172,171 @@ public sealed class MxGatewayClientCliTests
Assert.Equal("session-fixture", closeRequest.SessionId);
}
[Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new()
{
GalaxyTestConnectionReply = new TestConnectionReply { Ok = true },
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-test-connection",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyTestConnectionRequests);
Assert.Contains("\"ok\": true", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDiscoverHierarchyReply = new DiscoverHierarchyReply
{
Objects =
{
new GalaxyObject
{
GobjectId = 7,
TagName = "DelmiaReceiver_001",
ContainedName = "DelmiaReceiver",
ParentGobjectId = 1,
Attributes =
{
new GalaxyAttribute
{
AttributeName = "DownloadPath",
FullTagReference = "DelmiaReceiver_001.DownloadPath",
},
},
},
},
};
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-discover",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
Assert.Single(fakeClient.GalaxyDiscoverHierarchyRequests);
string text = output.ToString();
Assert.Contains("objects=1", text);
Assert.Contains("DelmiaReceiver_001", text);
Assert.Contains("attributes=1", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
DateTime deploy = new(2026, 4, 28, 14, 30, 0, DateTimeKind.Utc);
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 1,
ObservedAt = Timestamp.FromDateTime(deploy),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy),
TimeOfLastDeployPresent = true,
ObjectCount = 5,
AttributeCount = 17,
});
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 2,
ObservedAt = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeploy = Timestamp.FromDateTime(deploy.AddSeconds(30)),
TimeOfLastDeployPresent = true,
ObjectCount = 6,
AttributeCount = 18,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--last-seen-deploy-time",
"2026-04-28T14:00:00Z",
"--max-events",
"2",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
WatchDeployEventsRequest request = Assert.Single(fakeClient.GalaxyWatchDeployEventsRequests);
Assert.NotNull(request.LastSeenDeployTime);
string text = output.ToString();
Assert.Contains("sequence=1", text);
Assert.Contains("sequence=2", text);
Assert.Contains("objects=5", text);
Assert.Contains("attributes=18", text);
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{
using var output = new StringWriter();
using var error = new StringWriter();
FakeCliClient fakeClient = new();
fakeClient.GalaxyDeployEvents.Add(new DeployEvent
{
Sequence = 42,
ObjectCount = 99,
AttributeCount = 1024,
});
int exitCode = await MxGatewayClientCli.RunAsync(
[
"galaxy-watch",
"--endpoint",
"http://localhost:5000",
"--api-key",
"test-api-key",
"--max-events",
"1",
"--json",
],
output,
error,
_ => fakeClient);
Assert.Equal(0, exitCode);
string text = output.ToString();
Assert.Contains("\"sequence\": \"42\"", text);
Assert.Contains("\"objectCount\": 99", text);
}
private sealed class FakeCliClient : IMxGatewayCliClient
{
public Queue<MxCommandReply> InvokeReplies { get; } = new();
@@ -237,5 +404,58 @@ public sealed class MxGatewayClientCliTests
yield return gatewayEvent;
}
}
public TestConnectionReply GalaxyTestConnectionReply { get; set; } = new() { Ok = true };
public GetLastDeployTimeReply GalaxyGetLastDeployTimeReply { get; set; } = new() { Present = false };
public DiscoverHierarchyReply GalaxyDiscoverHierarchyReply { get; set; } = new();
public List<TestConnectionRequest> GalaxyTestConnectionRequests { get; } = [];
public List<GetLastDeployTimeRequest> GalaxyGetLastDeployTimeRequests { get; } = [];
public List<DiscoverHierarchyRequest> GalaxyDiscoverHierarchyRequests { get; } = [];
public Task<TestConnectionReply> GalaxyTestConnectionAsync(
TestConnectionRequest request,
CancellationToken cancellationToken)
{
GalaxyTestConnectionRequests.Add(request);
return Task.FromResult(GalaxyTestConnectionReply);
}
public Task<GetLastDeployTimeReply> GalaxyGetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken)
{
GalaxyGetLastDeployTimeRequests.Add(request);
return Task.FromResult(GalaxyGetLastDeployTimeReply);
}
public Task<DiscoverHierarchyReply> GalaxyDiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken)
{
GalaxyDiscoverHierarchyRequests.Add(request);
return Task.FromResult(GalaxyDiscoverHierarchyReply);
}
public List<WatchDeployEventsRequest> GalaxyWatchDeployEventsRequests { get; } = [];
public List<DeployEvent> GalaxyDeployEvents { get; } = [];
public async IAsyncEnumerable<DeployEvent> GalaxyWatchDeployEventsAsync(
WatchDeployEventsRequest request,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
GalaxyWatchDeployEventsRequests.Add(request);
foreach (DeployEvent deployEvent in GalaxyDeployEvents)
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return deployEvent;
}
}
}
}
@@ -0,0 +1,308 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using Polly;
using System.Net.Http;
using System.Net.Security;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
namespace MxGateway.Client;
/// <summary>
/// Provides the .NET client entry point for the public Galaxy Repository gRPC API.
/// All RPCs are read-only metadata calls that share the gateway's API-key auth
/// interceptor and require the <c>metadata:read</c> scope server-side.
/// </summary>
public sealed class GalaxyRepositoryClient : IAsyncDisposable
{
private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport;
private readonly ResiliencePipeline _safeUnaryRetryPipeline;
private bool _disposed;
internal GalaxyRepositoryClient(
MxGatewayClientOptions options,
IGalaxyRepositoryClientTransport transport)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
Options = options;
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
options.Retry,
options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
_channel = null;
}
private GalaxyRepositoryClient(
GrpcChannel channel,
IGalaxyRepositoryClientTransport transport)
{
_channel = channel;
_transport = transport;
Options = transport.Options;
_safeUnaryRetryPipeline = MxGatewayClientRetryPolicy.Create(
Options.Retry,
Options.LoggerFactory?.CreateLogger<GalaxyRepositoryClient>());
}
public MxGatewayClientOptions Options { get; }
public GalaxyRepository.GalaxyRepositoryClient RawClient =>
_transport.RawClient
?? throw new InvalidOperationException("The raw generated gRPC client is not available for this client instance.");
public static GalaxyRepositoryClient Create(MxGatewayClientOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.Validate();
HttpMessageHandler handler = CreateHttpHandler(options);
var channel = GrpcChannel.ForAddress(
options.Endpoint,
new GrpcChannelOptions
{
HttpHandler = handler,
LoggerFactory = options.LoggerFactory,
});
return new GalaxyRepositoryClient(
channel,
new GrpcGalaxyRepositoryClientTransport(
options,
new GalaxyRepository.GalaxyRepositoryClient(channel)));
}
/// <summary>
/// Probes the Galaxy Repository database connection. Returns true when the
/// gateway can reach the configured ZB SQL Server.
/// </summary>
public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken = default)
{
TestConnectionReply reply = await TestConnectionRawAsync(
new TestConnectionRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Ok;
}
public Task<TestConnectionReply> TestConnectionRawAsync(
TestConnectionRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.TestConnectionAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Returns the timestamp of the most recent Galaxy deployment, or
/// <see langword="null"/> when no deployment has been recorded.
/// </summary>
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken cancellationToken = default)
{
GetLastDeployTimeReply reply = await GetLastDeployTimeRawAsync(
new GetLastDeployTimeRequest(),
cancellationToken)
.ConfigureAwait(false);
if (!reply.Present || reply.TimeOfLastDeploy is null)
{
return null;
}
return reply.TimeOfLastDeploy.ToDateTime();
}
public Task<GetLastDeployTimeReply> GetLastDeployTimeRawAsync(
GetLastDeployTimeRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.GetLastDeployTimeAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Enumerates the deployed Galaxy object hierarchy. Each <see cref="GalaxyObject"/>
/// includes its dynamic attributes so callers can determine which tag references
/// they may subscribe to via the MxAccessGateway service.
/// </summary>
public async Task<IReadOnlyList<GalaxyObject>> DiscoverHierarchyAsync(CancellationToken cancellationToken = default)
{
DiscoverHierarchyReply reply = await DiscoverHierarchyRawAsync(
new DiscoverHierarchyRequest(),
cancellationToken)
.ConfigureAwait(false);
return reply.Objects;
}
public Task<DiscoverHierarchyReply> DiscoverHierarchyRawAsync(
DiscoverHierarchyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.DiscoverHierarchyAsync(request, CreateCallOptions(token)),
cancellationToken);
}
/// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event
/// per new <c>time_of_last_deploy</c>. Pass <paramref name="lastSeenDeployTime"/> to
/// suppress the bootstrap when the caller already holds the current deploy time.
/// </summary>
/// <remarks>
/// Streaming RPCs are not wrapped by the unary safe-read retry pipeline. If the
/// stream is interrupted the caller must reopen it; the server does not guarantee
/// at-least-once delivery beyond the per-subscriber buffer (gaps in
/// <see cref="DeployEvent.Sequence"/> indicate dropped events).
/// </remarks>
public IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
DateTimeOffset? lastSeenDeployTime = null,
CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
WatchDeployEventsRequest request = new();
if (lastSeenDeployTime is { } seen)
{
request.LastSeenDeployTime = Timestamp.FromDateTimeOffset(seen);
}
return WatchDeployEventsRawAsync(request, cancellationToken);
}
public IAsyncEnumerable<DeployEvent> WatchDeployEventsRawAsync(
WatchDeployEventsRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return WatchDeployEventsCoreAsync(request, cancellationToken);
}
private async IAsyncEnumerable<DeployEvent> WatchDeployEventsCoreAsync(
WatchDeployEventsRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (DeployEvent deployEvent in _transport
.WatchDeployEventsAsync(request, CreateStreamCallOptions(cancellationToken))
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return deployEvent;
}
}
public ValueTask DisposeAsync()
{
if (_disposed)
{
return ValueTask.CompletedTask;
}
_disposed = true;
_channel?.Dispose();
return ValueTask.CompletedTask;
}
internal CallOptions CreateCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.DefaultCallTimeout);
}
internal CallOptions CreateStreamCallOptions(CancellationToken cancellationToken)
{
return CreateCallOptions(cancellationToken, Options.StreamTimeout);
}
internal CallOptions CreateCallOptions(
CancellationToken cancellationToken,
TimeSpan? timeout)
{
Metadata headers = new()
{
{ "authorization", $"Bearer {Options.ApiKey}" },
};
return new CallOptions(
headers,
timeout is null ? null : DateTime.UtcNow.Add(timeout.Value),
cancellationToken);
}
private async Task<T> ExecuteSafeUnaryAsync<T>(
Func<CancellationToken, Task<T>> call,
CancellationToken cancellationToken)
{
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(Options.DefaultCallTimeout);
return await _safeUnaryRetryPipeline.ExecuteAsync(
async token => await call(token).ConfigureAwait(false),
timeout.Token)
.ConfigureAwait(false);
}
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options)
{
SocketsHttpHandler handler = new()
{
ConnectTimeout = options.ConnectTimeout,
};
if (options.UseTls)
{
handler.SslOptions = new SslClientAuthenticationOptions();
if (!string.IsNullOrWhiteSpace(options.ServerNameOverride))
{
handler.SslOptions.TargetHost = options.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(options.CaCertificatePath))
{
X509Certificate2 trustedRoot = X509CertificateLoader.LoadCertificateFromFile(options.CaCertificatePath);
handler.SslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, errors) =>
{
if (certificate is null)
{
return false;
}
using X509Chain customChain = new();
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
customChain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
customChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
customChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
X509Certificate2 certificateToValidate = certificate as X509Certificate2
?? X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
return customChain.Build(certificateToValidate);
};
}
}
return handler;
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
}
@@ -0,0 +1,127 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{
public MxGatewayClientOptions Options { get; } = options;
public GalaxyRepository.GalaxyRepositoryClient RawClient { get; } = rawClient;
GalaxyRepository.GalaxyRepositoryClient? IGalaxyRepositoryClientTransport.RawClient => RawClient;
public async Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.TestConnectionAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.GetLastDeployTimeAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.DiscoverHierarchyAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
CancellationToken effectiveCancellationToken = cancellationToken.CanBeCanceled
? cancellationToken
: callOptions.CancellationToken;
using AsyncServerStreamingCall<DeployEvent> call = RawClient.WatchDeployEvents(request, callOptions);
IAsyncStreamReader<DeployEvent> responseStream = call.ResponseStream;
while (true)
{
DeployEvent? deployEvent;
try
{
if (!await responseStream.MoveNext(effectiveCancellationToken).ConfigureAwait(false))
{
break;
}
deployEvent = responseStream.Current;
}
catch (RpcException exception)
{
throw MapRpcException(exception, effectiveCancellationToken);
}
yield return deployEvent;
}
}
IAsyncEnumerable<DeployEvent> IGalaxyRepositoryClientTransport.WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions)
{
return WatchDeployEventsAsync(request, callOptions);
}
private static Exception MapRpcException(
RpcException exception,
CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested || exception.StatusCode == StatusCode.Cancelled)
{
return new OperationCanceledException(
exception.Status.Detail,
exception,
cancellationToken);
}
return exception.StatusCode switch
{
StatusCode.Unauthenticated => new MxGatewayAuthenticationException(
exception.Status.Detail,
innerException: exception),
StatusCode.PermissionDenied => new MxGatewayAuthorizationException(
exception.Status.Detail,
innerException: exception),
_ => new MxGatewayException(exception.Status.Detail, exception),
};
}
}
@@ -0,0 +1,27 @@
using Grpc.Core;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Client;
internal interface IGalaxyRepositoryClientTransport
{
MxGatewayClientOptions Options { get; }
GalaxyRepository.GalaxyRepositoryClient? RawClient { get; }
Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request,
CallOptions callOptions);
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request,
CallOptions callOptions);
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request,
CallOptions callOptions);
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request,
CallOptions callOptions);
}
+76
View File
@@ -133,6 +133,82 @@ optionally writes a value when `--type` and `--value` are supplied, reads a
bounded event stream, and closes the session in a `finally` block. CLI error
output redacts API keys supplied through `--api-key`.
## Galaxy Repository Browse
`GalaxyRepositoryClient` is a separate read-only wrapper around the
`GalaxyRepository` gRPC service exposed by the same gateway. It shares the API
key auth interceptor with `MxGatewayClient` and requires the `metadata:read`
scope server-side. Use it to probe the ZB SQL connection, watch
`time_of_last_deploy` for redeployments, and enumerate the deployed Galaxy
object hierarchy plus each object's dynamic attributes.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = apiKey,
});
bool ok = await repository.TestConnectionAsync();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync();
IReadOnlyList<GalaxyObject> objects = await repository.DiscoverHierarchyAsync();
foreach (GalaxyObject galaxyObject in objects)
{
Console.WriteLine($"{galaxyObject.TagName} ({galaxyObject.ContainedName})");
foreach (GalaxyAttribute attribute in galaxyObject.Attributes)
{
Console.WriteLine($" {attribute.AttributeName} -> {attribute.FullTagReference}");
}
}
```
The CLI exposes the same operations:
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-last-deploy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
```
### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
server emits a bootstrap event with the current state on subscribe, then one
event per new `time_of_last_deploy`. Pass a `lastSeenDeployTime` to suppress the
bootstrap when the caller already holds the current deploy time. Use the
monotonic `Sequence` field to detect dropped events: gaps mean the
per-subscriber server-side buffer overflowed and the caller should reconcile.
Streaming RPCs are not wrapped by the unary safe-read retry pipeline. The
caller is responsible for reopening the stream on transient failures.
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(options);
DateTimeOffset? lastSeen = null;
await foreach (DeployEvent evt in repository.WatchDeployEventsAsync(
lastSeen,
cancellationToken))
{
Console.WriteLine(
$"seq={evt.Sequence} objects={evt.ObjectCount} attributes={evt.AttributeCount}");
if (evt.TimeOfLastDeployPresent && evt.TimeOfLastDeploy is not null)
{
lastSeen = evt.TimeOfLastDeploy.ToDateTimeOffset();
}
}
```
The CLI counterpart streams events until Ctrl+C (or `--max-events`):
```powershell
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T14:30:00Z --json
dotnet run --project clients/dotnet/MxGateway.Client.Cli -- galaxy-watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --max-events 5 --json
```
Use TLS options for a secured gateway:
```powershell
+85
View File
@@ -84,6 +84,87 @@ goroutine cleanup. Raw protobuf messages remain available through the
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply.
## Galaxy Repository browse
The `GalaxyRepository` service (proto package `galaxy_repository.v1`) is a
read-only metadata-only browse over the AVEVA System Platform Galaxy
Repository. It uses the same API-key authentication as the MXAccess Gateway
and requires the `metadata:read` scope. Use `mxgateway.DialGalaxy` to obtain a
`*GalaxyClient` that mirrors the connection-management conventions of
`Client`:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
return err
}
defer galaxy.Close()
ok, err := galaxy.TestConnection(ctx)
deployTime, present, err := galaxy.GetLastDeployTime(ctx)
objects, err := galaxy.DiscoverHierarchy(ctx)
```
`GetLastDeployTime` returns `(time.Time{}, false, nil)` when the server
reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access.
### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
bootstrap event with the current Galaxy state immediately on subscribe, then
one `DeployEvent` per new deploy. `Sequence` is monotonic per server start;
gaps signal dropped events. Pass a non-nil `lastSeenDeployTime` to suppress the
bootstrap event when resuming from a known checkpoint:
```go
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
events, errs, err := galaxy.WatchDeployEvents(streamCtx, nil)
if err != nil {
return err
}
for {
select {
case ev, ok := <-events:
if !ok {
return nil // stream completed (server EOF or ctx cancelled)
}
log.Printf("seq=%d objects=%d attrs=%d",
ev.GetSequence(), ev.GetObjectCount(), ev.GetAttributeCount())
case streamErr := <-errs:
if streamErr != nil {
return streamErr // *GatewayError
}
case <-ctx.Done():
return ctx.Err()
}
}
```
Cancel the supplied context to tear down the stream cleanly. Both channels
close after EOF, cancellation, or a terminal error; surfaced errors are wrapped
in `*GatewayError`.
The CLI exposes the same RPC via `galaxy-watch`:
```powershell
go run ./cmd/mxgw-go galaxy-watch -plaintext
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -last-seen-deploy-time 2026-04-28T10:00:00Z
go run ./cmd/mxgw-go galaxy-watch -plaintext -limit 5
```
The command runs until Ctrl+C (or the optional `-limit` is reached) and prints
one line per event in text mode or one JSON object per event with `-json`.
## CLI
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
@@ -98,6 +179,10 @@ go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -pl
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
```
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
+232 -1
View File
@@ -8,8 +8,10 @@ import (
"fmt"
"io"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
@@ -88,6 +90,14 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runStreamEvents(ctx, args[1:], stdout, stderr)
case "smoke":
return runSmoke(ctx, args[1:], stdout, stderr)
case "galaxy-test-connection":
return runGalaxyTestConnection(ctx, args[1:], stdout, stderr)
case "galaxy-last-deploy":
return runGalaxyLastDeploy(ctx, args[1:], stdout, stderr)
case "galaxy-discover":
return runGalaxyDiscover(ctx, args[1:], stdout, stderr)
case "galaxy-watch":
return runGalaxyWatch(ctx, args[1:], stdout, stderr)
default:
writeUsage(stderr)
return fmt.Errorf("unknown command %q", args[0])
@@ -651,5 +661,226 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke>")
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|register|add-item|advise|subscribe-bulk|unsubscribe-bulk|write|stream-events|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch>")
}
func dialGalaxyForCommand(ctx context.Context, common *commonOptions) (*mxgateway.GalaxyClient, commonOptions, error) {
options, err := common.resolved()
if err != nil {
return nil, options, err
}
client, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: options.Endpoint,
APIKey: options.apiKeyValue,
Plaintext: options.Plaintext,
CACertFile: options.CACertFile,
ServerNameOverride: options.ServerName,
CallTimeout: options.timeout,
})
return client, options, err
}
func runGalaxyTestConnection(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-test-connection", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
ok, err := client.TestConnection(ctx)
if err != nil {
return err
}
if *jsonOutput {
return writeJSON(stdout, map[string]any{
"command": "galaxy-test-connection",
"options": options,
"ok": ok,
})
}
fmt.Fprintln(stdout, ok)
return nil
}
func runGalaxyLastDeploy(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-last-deploy", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
deployTime, present, err := client.GetLastDeployTime(ctx)
if err != nil {
return err
}
if *jsonOutput {
payload := map[string]any{
"command": "galaxy-last-deploy",
"options": options,
"present": present,
}
if present {
payload["timeOfLastDeploy"] = deployTime.UTC().Format(time.RFC3339Nano)
}
return writeJSON(stdout, payload)
}
if !present {
fmt.Fprintln(stdout, "absent")
return nil
}
fmt.Fprintln(stdout, deployTime.UTC().Format(time.RFC3339Nano))
return nil
}
func runGalaxyDiscover(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-discover", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
if err := flags.Parse(args); err != nil {
return err
}
client, options, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
objects, err := client.DiscoverHierarchy(ctx)
if err != nil {
return err
}
if *jsonOutput {
marshaled := make([]json.RawMessage, 0, len(objects))
for _, obj := range objects {
marshaled = append(marshaled, mustMarshalProto(obj))
}
return writeJSON(stdout, map[string]any{
"command": "galaxy-discover",
"options": options,
"objects": marshaled,
})
}
for _, obj := range objects {
fmt.Fprintf(stdout, "%d\t%s\t%s\t(attrs=%d)\n", obj.GetGobjectId(), obj.GetTagName(), obj.GetContainedName(), len(obj.GetAttributes()))
}
return nil
}
func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("galaxy-watch", flag.ContinueOnError)
flags.SetOutput(stderr)
common := bindCommonFlags(flags)
jsonOutput := flags.Bool("json", false, "write JSON output")
lastSeen := flags.String("last-seen-deploy-time", "", "RFC3339 timestamp; when set, suppresses the bootstrap event")
limit := flags.Int("limit", 0, "maximum events to read; 0 means unbounded (Ctrl+C to stop)")
if err := flags.Parse(args); err != nil {
return err
}
var lastSeenPtr *time.Time
if *lastSeen != "" {
parsed, err := time.Parse(time.RFC3339, *lastSeen)
if err != nil {
return fmt.Errorf("invalid -last-seen-deploy-time: %w", err)
}
lastSeenPtr = &parsed
}
client, _, err := dialGalaxyForCommand(ctx, common)
if err != nil {
return err
}
defer client.Close()
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stopSignals()
streamCtx, cancelStream := context.WithCancel(signalCtx)
defer cancelStream()
events, errs, err := client.WatchDeployEvents(streamCtx, lastSeenPtr)
if err != nil {
return err
}
count := 0
for {
select {
case event, ok := <-events:
if !ok {
// Drain any terminal error before returning.
if streamErr, errOk := <-errs; errOk && streamErr != nil {
return streamErr
}
return nil
}
if *jsonOutput {
fmt.Fprintln(stdout, string(mustMarshalProto(event)))
} else {
fmt.Fprintln(stdout, formatDeployEvent(event))
}
count++
if *limit > 0 && count >= *limit {
cancelStream()
return nil
}
case streamErr, ok := <-errs:
if !ok {
return nil
}
if streamErr != nil {
return streamErr
}
case <-signalCtx.Done():
cancelStream()
// Allow goroutine to drain.
for range events {
}
return nil
}
}
}
func formatDeployEvent(event *mxgateway.DeployEvent) string {
observed := ""
if ts := event.GetObservedAt(); ts != nil {
observed = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
deploy := "absent"
if event.GetTimeOfLastDeployPresent() {
if ts := event.GetTimeOfLastDeploy(); ts != nil {
deploy = ts.AsTime().UTC().Format(time.RFC3339Nano)
}
}
return fmt.Sprintf(
"seq=%d observed=%s deploy=%s objects=%d attributes=%d",
event.GetSequence(),
observed,
deploy,
event.GetObjectCount(),
event.GetAttributeCount(),
)
}
+6 -2
View File
@@ -30,13 +30,17 @@ $env:Path = "$goPluginPath;$env:Path"
--go_opt=paths=source_relative `
"--go_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
"--go_opt=Mmxaccess_worker.proto=$modulePath;generated" `
"--go_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
& $protoc `
--proto_path=$protoRoot `
--go-grpc_out=$outputRoot `
--go-grpc_opt=paths=source_relative `
"--go-grpc_opt=Mmxaccess_gateway.proto=$modulePath;generated" `
mxaccess_gateway.proto
"--go-grpc_opt=Mgalaxy_repository.proto=$modulePath;generated" `
mxaccess_gateway.proto `
galaxy_repository.proto
@@ -0,0 +1,778 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type TestConnectionRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionRequest) Reset() {
*x = TestConnectionRequest{}
mi := &file_galaxy_repository_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionRequest) ProtoMessage() {}
func (x *TestConnectionRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionRequest.ProtoReflect.Descriptor instead.
func (*TestConnectionRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{0}
}
type TestConnectionReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TestConnectionReply) Reset() {
*x = TestConnectionReply{}
mi := &file_galaxy_repository_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TestConnectionReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TestConnectionReply) ProtoMessage() {}
func (x *TestConnectionReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TestConnectionReply.ProtoReflect.Descriptor instead.
func (*TestConnectionReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{1}
}
func (x *TestConnectionReply) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
type GetLastDeployTimeRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeRequest) Reset() {
*x = GetLastDeployTimeRequest{}
mi := &file_galaxy_repository_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeRequest) ProtoMessage() {}
func (x *GetLastDeployTimeRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeRequest.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{2}
}
type GetLastDeployTimeReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Present bool `protobuf:"varint,1,opt,name=present,proto3" json:"present,omitempty"`
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetLastDeployTimeReply) Reset() {
*x = GetLastDeployTimeReply{}
mi := &file_galaxy_repository_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetLastDeployTimeReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetLastDeployTimeReply) ProtoMessage() {}
func (x *GetLastDeployTimeReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetLastDeployTimeReply.ProtoReflect.Descriptor instead.
func (*GetLastDeployTimeReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{3}
}
func (x *GetLastDeployTimeReply) GetPresent() bool {
if x != nil {
return x.Present
}
return false
}
func (x *GetLastDeployTimeReply) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
type DiscoverHierarchyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyRequest) Reset() {
*x = DiscoverHierarchyRequest{}
mi := &file_galaxy_repository_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyRequest) ProtoMessage() {}
func (x *DiscoverHierarchyRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyRequest.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{4}
}
type DiscoverHierarchyReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Objects []*GalaxyObject `protobuf:"bytes,1,rep,name=objects,proto3" json:"objects,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DiscoverHierarchyReply) Reset() {
*x = DiscoverHierarchyReply{}
mi := &file_galaxy_repository_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DiscoverHierarchyReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DiscoverHierarchyReply) ProtoMessage() {}
func (x *DiscoverHierarchyReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DiscoverHierarchyReply.ProtoReflect.Descriptor instead.
func (*DiscoverHierarchyReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{5}
}
func (x *DiscoverHierarchyReply) GetObjects() []*GalaxyObject {
if x != nil {
return x.Objects
}
return nil
}
type WatchDeployEventsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Optional. When set, the bootstrap event is suppressed if the cached deploy
// time matches this value. Future events are still emitted normally.
LastSeenDeployTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=last_seen_deploy_time,json=lastSeenDeployTime,proto3" json:"last_seen_deploy_time,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *WatchDeployEventsRequest) Reset() {
*x = WatchDeployEventsRequest{}
mi := &file_galaxy_repository_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *WatchDeployEventsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*WatchDeployEventsRequest) ProtoMessage() {}
func (x *WatchDeployEventsRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use WatchDeployEventsRequest.ProtoReflect.Descriptor instead.
func (*WatchDeployEventsRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{6}
}
func (x *WatchDeployEventsRequest) GetLastSeenDeployTime() *timestamppb.Timestamp {
if x != nil {
return x.LastSeenDeployTime
}
return nil
}
type DeployEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Monotonically increasing per server start. Gaps indicate dropped events.
Sequence uint64 `protobuf:"varint,1,opt,name=sequence,proto3" json:"sequence,omitempty"`
// Server wall-clock when the cache observed the deploy.
ObservedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=observed_at,json=observedAt,proto3" json:"observed_at,omitempty"`
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
TimeOfLastDeploy *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=time_of_last_deploy,json=timeOfLastDeploy,proto3" json:"time_of_last_deploy,omitempty"`
TimeOfLastDeployPresent bool `protobuf:"varint,4,opt,name=time_of_last_deploy_present,json=timeOfLastDeployPresent,proto3" json:"time_of_last_deploy_present,omitempty"`
ObjectCount int32 `protobuf:"varint,5,opt,name=object_count,json=objectCount,proto3" json:"object_count,omitempty"`
AttributeCount int32 `protobuf:"varint,6,opt,name=attribute_count,json=attributeCount,proto3" json:"attribute_count,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeployEvent) Reset() {
*x = DeployEvent{}
mi := &file_galaxy_repository_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeployEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeployEvent) ProtoMessage() {}
func (x *DeployEvent) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeployEvent.ProtoReflect.Descriptor instead.
func (*DeployEvent) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{7}
}
func (x *DeployEvent) GetSequence() uint64 {
if x != nil {
return x.Sequence
}
return 0
}
func (x *DeployEvent) GetObservedAt() *timestamppb.Timestamp {
if x != nil {
return x.ObservedAt
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeploy() *timestamppb.Timestamp {
if x != nil {
return x.TimeOfLastDeploy
}
return nil
}
func (x *DeployEvent) GetTimeOfLastDeployPresent() bool {
if x != nil {
return x.TimeOfLastDeployPresent
}
return false
}
func (x *DeployEvent) GetObjectCount() int32 {
if x != nil {
return x.ObjectCount
}
return 0
}
func (x *DeployEvent) GetAttributeCount() int32 {
if x != nil {
return x.AttributeCount
}
return 0
}
type GalaxyObject struct {
state protoimpl.MessageState `protogen:"open.v1"`
GobjectId int32 `protobuf:"varint,1,opt,name=gobject_id,json=gobjectId,proto3" json:"gobject_id,omitempty"`
TagName string `protobuf:"bytes,2,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"`
ContainedName string `protobuf:"bytes,3,opt,name=contained_name,json=containedName,proto3" json:"contained_name,omitempty"`
BrowseName string `protobuf:"bytes,4,opt,name=browse_name,json=browseName,proto3" json:"browse_name,omitempty"`
ParentGobjectId int32 `protobuf:"varint,5,opt,name=parent_gobject_id,json=parentGobjectId,proto3" json:"parent_gobject_id,omitempty"`
IsArea bool `protobuf:"varint,6,opt,name=is_area,json=isArea,proto3" json:"is_area,omitempty"`
CategoryId int32 `protobuf:"varint,7,opt,name=category_id,json=categoryId,proto3" json:"category_id,omitempty"`
HostedByGobjectId int32 `protobuf:"varint,8,opt,name=hosted_by_gobject_id,json=hostedByGobjectId,proto3" json:"hosted_by_gobject_id,omitempty"`
TemplateChain []string `protobuf:"bytes,9,rep,name=template_chain,json=templateChain,proto3" json:"template_chain,omitempty"`
Attributes []*GalaxyAttribute `protobuf:"bytes,10,rep,name=attributes,proto3" json:"attributes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyObject) Reset() {
*x = GalaxyObject{}
mi := &file_galaxy_repository_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyObject) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyObject) ProtoMessage() {}
func (x *GalaxyObject) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyObject.ProtoReflect.Descriptor instead.
func (*GalaxyObject) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{8}
}
func (x *GalaxyObject) GetGobjectId() int32 {
if x != nil {
return x.GobjectId
}
return 0
}
func (x *GalaxyObject) GetTagName() string {
if x != nil {
return x.TagName
}
return ""
}
func (x *GalaxyObject) GetContainedName() string {
if x != nil {
return x.ContainedName
}
return ""
}
func (x *GalaxyObject) GetBrowseName() string {
if x != nil {
return x.BrowseName
}
return ""
}
func (x *GalaxyObject) GetParentGobjectId() int32 {
if x != nil {
return x.ParentGobjectId
}
return 0
}
func (x *GalaxyObject) GetIsArea() bool {
if x != nil {
return x.IsArea
}
return false
}
func (x *GalaxyObject) GetCategoryId() int32 {
if x != nil {
return x.CategoryId
}
return 0
}
func (x *GalaxyObject) GetHostedByGobjectId() int32 {
if x != nil {
return x.HostedByGobjectId
}
return 0
}
func (x *GalaxyObject) GetTemplateChain() []string {
if x != nil {
return x.TemplateChain
}
return nil
}
func (x *GalaxyObject) GetAttributes() []*GalaxyAttribute {
if x != nil {
return x.Attributes
}
return nil
}
type GalaxyAttribute struct {
state protoimpl.MessageState `protogen:"open.v1"`
AttributeName string `protobuf:"bytes,1,opt,name=attribute_name,json=attributeName,proto3" json:"attribute_name,omitempty"`
FullTagReference string `protobuf:"bytes,2,opt,name=full_tag_reference,json=fullTagReference,proto3" json:"full_tag_reference,omitempty"`
MxDataType int32 `protobuf:"varint,3,opt,name=mx_data_type,json=mxDataType,proto3" json:"mx_data_type,omitempty"`
DataTypeName string `protobuf:"bytes,4,opt,name=data_type_name,json=dataTypeName,proto3" json:"data_type_name,omitempty"`
IsArray bool `protobuf:"varint,5,opt,name=is_array,json=isArray,proto3" json:"is_array,omitempty"`
ArrayDimension int32 `protobuf:"varint,6,opt,name=array_dimension,json=arrayDimension,proto3" json:"array_dimension,omitempty"`
ArrayDimensionPresent bool `protobuf:"varint,7,opt,name=array_dimension_present,json=arrayDimensionPresent,proto3" json:"array_dimension_present,omitempty"`
MxAttributeCategory int32 `protobuf:"varint,8,opt,name=mx_attribute_category,json=mxAttributeCategory,proto3" json:"mx_attribute_category,omitempty"`
SecurityClassification int32 `protobuf:"varint,9,opt,name=security_classification,json=securityClassification,proto3" json:"security_classification,omitempty"`
IsHistorized bool `protobuf:"varint,10,opt,name=is_historized,json=isHistorized,proto3" json:"is_historized,omitempty"`
IsAlarm bool `protobuf:"varint,11,opt,name=is_alarm,json=isAlarm,proto3" json:"is_alarm,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GalaxyAttribute) Reset() {
*x = GalaxyAttribute{}
mi := &file_galaxy_repository_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GalaxyAttribute) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GalaxyAttribute) ProtoMessage() {}
func (x *GalaxyAttribute) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GalaxyAttribute.ProtoReflect.Descriptor instead.
func (*GalaxyAttribute) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{9}
}
func (x *GalaxyAttribute) GetAttributeName() string {
if x != nil {
return x.AttributeName
}
return ""
}
func (x *GalaxyAttribute) GetFullTagReference() string {
if x != nil {
return x.FullTagReference
}
return ""
}
func (x *GalaxyAttribute) GetMxDataType() int32 {
if x != nil {
return x.MxDataType
}
return 0
}
func (x *GalaxyAttribute) GetDataTypeName() string {
if x != nil {
return x.DataTypeName
}
return ""
}
func (x *GalaxyAttribute) GetIsArray() bool {
if x != nil {
return x.IsArray
}
return false
}
func (x *GalaxyAttribute) GetArrayDimension() int32 {
if x != nil {
return x.ArrayDimension
}
return 0
}
func (x *GalaxyAttribute) GetArrayDimensionPresent() bool {
if x != nil {
return x.ArrayDimensionPresent
}
return false
}
func (x *GalaxyAttribute) GetMxAttributeCategory() int32 {
if x != nil {
return x.MxAttributeCategory
}
return 0
}
func (x *GalaxyAttribute) GetSecurityClassification() int32 {
if x != nil {
return x.SecurityClassification
}
return 0
}
func (x *GalaxyAttribute) GetIsHistorized() bool {
if x != nil {
return x.IsHistorized
}
return false
}
func (x *GalaxyAttribute) GetIsAlarm() bool {
if x != nil {
return x.IsAlarm
}
return false
}
var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" +
"\n" +
"\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n" +
"\x15TestConnectionRequest\"%\n" +
"\x13TestConnectionReply\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\"\x1a\n" +
"\x18GetLastDeployTimeRequest\"}\n" +
"\x16GetLastDeployTimeReply\x12\x18\n" +
"\apresent\x18\x01 \x01(\bR\apresent\x12I\n" +
"\x13time_of_last_deploy\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\"\x1a\n" +
"\x18DiscoverHierarchyRequest\"V\n" +
"\x16DiscoverHierarchyReply\x12<\n" +
"\aobjects\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\aobjects\"i\n" +
"\x18WatchDeployEventsRequest\x12M\n" +
"\x15last_seen_deploy_time\x18\x01 \x01(\v2\x1a.google.protobuf.TimestampR\x12lastSeenDeployTime\"\xbb\x02\n" +
"\vDeployEvent\x12\x1a\n" +
"\bsequence\x18\x01 \x01(\x04R\bsequence\x12;\n" +
"\vobserved_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\n" +
"observedAt\x12I\n" +
"\x13time_of_last_deploy\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\x10timeOfLastDeploy\x12<\n" +
"\x1btime_of_last_deploy_present\x18\x04 \x01(\bR\x17timeOfLastDeployPresent\x12!\n" +
"\fobject_count\x18\x05 \x01(\x05R\vobjectCount\x12'\n" +
"\x0fattribute_count\x18\x06 \x01(\x05R\x0eattributeCount\"\x95\x03\n" +
"\fGalaxyObject\x12\x1d\n" +
"\n" +
"gobject_id\x18\x01 \x01(\x05R\tgobjectId\x12\x19\n" +
"\btag_name\x18\x02 \x01(\tR\atagName\x12%\n" +
"\x0econtained_name\x18\x03 \x01(\tR\rcontainedName\x12\x1f\n" +
"\vbrowse_name\x18\x04 \x01(\tR\n" +
"browseName\x12*\n" +
"\x11parent_gobject_id\x18\x05 \x01(\x05R\x0fparentGobjectId\x12\x17\n" +
"\ais_area\x18\x06 \x01(\bR\x06isArea\x12\x1f\n" +
"\vcategory_id\x18\a \x01(\x05R\n" +
"categoryId\x12/\n" +
"\x14hosted_by_gobject_id\x18\b \x01(\x05R\x11hostedByGobjectId\x12%\n" +
"\x0etemplate_chain\x18\t \x03(\tR\rtemplateChain\x12E\n" +
"\n" +
"attributes\x18\n" +
" \x03(\v2%.galaxy_repository.v1.GalaxyAttributeR\n" +
"attributes\"\xd7\x03\n" +
"\x0fGalaxyAttribute\x12%\n" +
"\x0eattribute_name\x18\x01 \x01(\tR\rattributeName\x12,\n" +
"\x12full_tag_reference\x18\x02 \x01(\tR\x10fullTagReference\x12 \n" +
"\fmx_data_type\x18\x03 \x01(\x05R\n" +
"mxDataType\x12$\n" +
"\x0edata_type_name\x18\x04 \x01(\tR\fdataTypeName\x12\x19\n" +
"\bis_array\x18\x05 \x01(\bR\aisArray\x12'\n" +
"\x0farray_dimension\x18\x06 \x01(\x05R\x0earrayDimension\x126\n" +
"\x17array_dimension_present\x18\a \x01(\bR\x15arrayDimensionPresent\x122\n" +
"\x15mx_attribute_category\x18\b \x01(\x05R\x13mxAttributeCategory\x127\n" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" +
"\x10GalaxyRepository\x12h\n" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var (
file_galaxy_repository_proto_rawDescOnce sync.Once
file_galaxy_repository_proto_rawDescData []byte
)
func file_galaxy_repository_proto_rawDescGZIP() []byte {
file_galaxy_repository_proto_rawDescOnce.Do(func() {
file_galaxy_repository_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)))
})
return file_galaxy_repository_proto_rawDescData
}
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
(*GetLastDeployTimeRequest)(nil), // 2: galaxy_repository.v1.GetLastDeployTimeRequest
(*GetLastDeployTimeReply)(nil), // 3: galaxy_repository.v1.GetLastDeployTimeReply
(*DiscoverHierarchyRequest)(nil), // 4: galaxy_repository.v1.DiscoverHierarchyRequest
(*DiscoverHierarchyReply)(nil), // 5: galaxy_repository.v1.DiscoverHierarchyReply
(*WatchDeployEventsRequest)(nil), // 6: galaxy_repository.v1.WatchDeployEventsRequest
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
}
var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
8, // 1: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 2: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 3: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 5: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 6: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
2, // 7: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
4, // 8: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
6, // 9: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
1, // 10: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
3, // 11: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
5, // 12: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // 13: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
10, // [10:14] is the sub-list for method output_type
6, // [6:10] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_galaxy_repository_proto_init() }
func file_galaxy_repository_proto_init() {
if File_galaxy_repository_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0,
NumMessages: 10,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_galaxy_repository_proto_goTypes,
DependencyIndexes: file_galaxy_repository_proto_depIdxs,
MessageInfos: file_galaxy_repository_proto_msgTypes,
}.Build()
File_galaxy_repository_proto = out.File
file_galaxy_repository_proto_goTypes = nil
file_galaxy_repository_proto_depIdxs = nil
}
@@ -0,0 +1,261 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v7.34.1
// source: galaxy_repository.proto
package generated
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
GalaxyRepository_TestConnection_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/TestConnection"
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
)
// GalaxyRepositoryClient is the client API for GalaxyRepository service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryClient interface {
TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error)
GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
}
type galaxyRepositoryClient struct {
cc grpc.ClientConnInterface
}
func NewGalaxyRepositoryClient(cc grpc.ClientConnInterface) GalaxyRepositoryClient {
return &galaxyRepositoryClient{cc}
}
func (c *galaxyRepositoryClient) TestConnection(ctx context.Context, in *TestConnectionRequest, opts ...grpc.CallOption) (*TestConnectionReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TestConnectionReply)
err := c.cc.Invoke(ctx, GalaxyRepository_TestConnection_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) GetLastDeployTime(ctx context.Context, in *GetLastDeployTimeRequest, opts ...grpc.CallOption) (*GetLastDeployTimeReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetLastDeployTimeReply)
err := c.cc.Invoke(ctx, GalaxyRepository_GetLastDeployTime_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) DiscoverHierarchy(ctx context.Context, in *DiscoverHierarchyRequest, opts ...grpc.CallOption) (*DiscoverHierarchyReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DiscoverHierarchyReply)
err := c.cc.Invoke(ctx, GalaxyRepository_DiscoverHierarchy_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &GalaxyRepository_ServiceDesc.Streams[0], GalaxyRepository_WatchDeployEvents_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[WatchDeployEventsRequest, DeployEvent]{ClientStream: stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
// GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility.
//
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
type GalaxyRepositoryServer interface {
TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error)
GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error)
DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error)
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
mustEmbedUnimplementedGalaxyRepositoryServer()
}
// UnimplementedGalaxyRepositoryServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedGalaxyRepositoryServer struct{}
func (UnimplementedGalaxyRepositoryServer) TestConnection(context.Context, *TestConnectionRequest) (*TestConnectionReply, error) {
return nil, status.Error(codes.Unimplemented, "method TestConnection not implemented")
}
func (UnimplementedGalaxyRepositoryServer) GetLastDeployTime(context.Context, *GetLastDeployTimeRequest) (*GetLastDeployTimeReply, error) {
return nil, status.Error(codes.Unimplemented, "method GetLastDeployTime not implemented")
}
func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *DiscoverHierarchyRequest) (*DiscoverHierarchyReply, error) {
return nil, status.Error(codes.Unimplemented, "method DiscoverHierarchy not implemented")
}
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
// UnsafeGalaxyRepositoryServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to GalaxyRepositoryServer will
// result in compilation errors.
type UnsafeGalaxyRepositoryServer interface {
mustEmbedUnimplementedGalaxyRepositoryServer()
}
func RegisterGalaxyRepositoryServer(s grpc.ServiceRegistrar, srv GalaxyRepositoryServer) {
// If the following call panics, it indicates UnimplementedGalaxyRepositoryServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&GalaxyRepository_ServiceDesc, srv)
}
func _GalaxyRepository_TestConnection_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TestConnectionRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_TestConnection_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).TestConnection(ctx, req.(*TestConnectionRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_GetLastDeployTime_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetLastDeployTimeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_GetLastDeployTime_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).GetLastDeployTime(ctx, req.(*GetLastDeployTimeRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_DiscoverHierarchy_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DiscoverHierarchyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_DiscoverHierarchy_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).DiscoverHierarchy(ctx, req.(*DiscoverHierarchyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(WatchDeployEventsRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(GalaxyRepositoryServer).WatchDeployEvents(m, &grpc.GenericServerStream[WatchDeployEventsRequest, DeployEvent]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
ServiceName: "galaxy_repository.v1.GalaxyRepository",
HandlerType: (*GalaxyRepositoryServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "TestConnection",
Handler: _GalaxyRepository_TestConnection_Handler,
},
{
MethodName: "GetLastDeployTime",
Handler: _GalaxyRepository_GetLastDeployTime_Handler,
},
{
MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "WatchDeployEvents",
Handler: _GalaxyRepository_WatchDeployEvents_Handler,
ServerStreams: true,
},
},
Metadata: "galaxy_repository.proto",
}
+246
View File
@@ -0,0 +1,246 @@
package mxgateway
import (
"context"
"errors"
"io"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
)
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract
// access.
type RawGalaxyRepositoryClient = pb.GalaxyRepositoryClient
// Generated protobuf aliases for Galaxy Repository messages.
type (
TestConnectionRequest = pb.TestConnectionRequest
TestConnectionReply = pb.TestConnectionReply
GetLastDeployTimeRequest = pb.GetLastDeployTimeRequest
GetLastDeployTimeReply = pb.GetLastDeployTimeReply
DiscoverHierarchyRequest = pb.DiscoverHierarchyRequest
DiscoverHierarchyReply = pb.DiscoverHierarchyReply
GalaxyObject = pb.GalaxyObject
GalaxyAttribute = pb.GalaxyAttribute
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
DeployEvent = pb.DeployEvent
)
// RawDeployEventStream is the generated WatchDeployEvents client stream.
type RawDeployEventStream = grpc.ServerStreamingClient[pb.DeployEvent]
// GalaxyClient owns a gateway gRPC connection and exposes Galaxy Repository
// browse helpers. It mirrors the structure of Client and uses the same
// connection-management conventions.
type GalaxyClient struct {
conn *grpc.ClientConn
raw pb.GalaxyRepositoryClient
opts Options
}
// DialGalaxy opens a gRPC connection to the gateway for the Galaxy Repository
// service. It applies the same authentication metadata, transport security,
// and dial-timeout behavior as Dial.
func DialGalaxy(ctx context.Context, opts Options) (*GalaxyClient, error) {
if opts.Endpoint == "" {
return nil, errors.New("mxgateway: endpoint is required")
}
dialCtx := ctx
cancel := func() {}
if opts.DialTimeout > 0 {
dialCtx, cancel = context.WithTimeout(ctx, opts.DialTimeout)
} else if _, ok := ctx.Deadline(); !ok {
dialCtx, cancel = context.WithTimeout(ctx, defaultDialTimeout)
}
defer cancel()
transportCredentials, err := resolveTransportCredentials(opts)
if err != nil {
return nil, err
}
dialOptions := []grpc.DialOption{
grpc.WithTransportCredentials(transportCredentials),
grpc.WithUnaryInterceptor(unaryAuthInterceptor(opts.APIKey)),
grpc.WithStreamInterceptor(streamAuthInterceptor(opts.APIKey)),
grpc.WithBlock(),
}
dialOptions = append(dialOptions, opts.DialOptions...)
conn, err := grpc.DialContext(dialCtx, opts.Endpoint, dialOptions...)
if err != nil {
return nil, &GatewayError{Op: "dial", Err: err}
}
return NewGalaxyClient(conn, opts), nil
}
// NewGalaxyClient wraps an existing gRPC connection for Galaxy Repository
// access. The caller owns closing conn unless it calls Close on the returned
// GalaxyClient.
func NewGalaxyClient(conn *grpc.ClientConn, opts Options) *GalaxyClient {
return &GalaxyClient{
conn: conn,
raw: pb.NewGalaxyRepositoryClient(conn),
opts: opts,
}
}
// RawClient returns the generated gRPC client for command-specific parity
// tests.
func (c *GalaxyClient) RawClient() RawGalaxyRepositoryClient {
return c.raw
}
// TestConnection probes the Galaxy Repository service. It returns the server's
// reported ok flag and a non-nil error only when the RPC itself fails.
func (c *GalaxyClient) TestConnection(ctx context.Context) (bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.TestConnection(callCtx, &pb.TestConnectionRequest{})
if err != nil {
return false, &GatewayError{Op: "galaxy test connection", Err: err}
}
return reply.GetOk(), nil
}
// GetLastDeployTime returns the Galaxy's last deploy timestamp. When the server
// reports present=false (no deploy recorded yet) the call returns
// (time.Time{}, false, nil). When present=true the timestamp is returned in
// UTC with present=true.
func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.GetLastDeployTime(callCtx, &pb.GetLastDeployTimeRequest{})
if err != nil {
return time.Time{}, false, &GatewayError{Op: "galaxy get last deploy time", Err: err}
}
if !reply.GetPresent() {
return time.Time{}, false, nil
}
ts := reply.GetTimeOfLastDeploy()
if ts == nil {
return time.Time{}, false, nil
}
return ts.AsTime(), true, nil
}
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
// object's dynamic attributes. The objects are returned in the order supplied
// by the server.
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
if err != nil {
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
}
return reply.GetObjects(), nil
}
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
// that want direct control over Recv. The caller owns the returned stream's
// lifetime via ctx cancellation.
func (c *GalaxyClient) WatchDeployEventsRaw(ctx context.Context, req *WatchDeployEventsRequest) (RawDeployEventStream, error) {
if req == nil {
req = &pb.WatchDeployEventsRequest{}
}
stream, err := c.raw.WatchDeployEvents(ctx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy watch deploy events", Err: err}
}
return stream, nil
}
// WatchDeployEvents subscribes to Galaxy deploy events. The server emits a
// bootstrap event with the current state immediately on subscribe, then one
// event per new deploy. When lastSeenDeployTime is non-nil it is forwarded to
// the server to suppress the bootstrap event.
//
// The returned event channel is closed when the server completes the stream
// (io.EOF), when ctx is cancelled, or after a terminal error has been
// delivered on the error channel. The error channel is also closed once the
// stream tears down. Surfaced errors are wrapped in *GatewayError.
//
// Cancel ctx to tear the stream down cleanly.
func (c *GalaxyClient) WatchDeployEvents(
ctx context.Context,
lastSeenDeployTime *time.Time,
) (<-chan *DeployEvent, <-chan error, error) {
req := &pb.WatchDeployEventsRequest{}
if lastSeenDeployTime != nil {
req.LastSeenDeployTime = timestamppb.New(*lastSeenDeployTime)
}
stream, err := c.WatchDeployEventsRaw(ctx, req)
if err != nil {
return nil, nil, err
}
events := make(chan *DeployEvent, 16)
errs := make(chan error, 1)
go func() {
defer close(events)
defer close(errs)
for {
event, recvErr := stream.Recv()
if recvErr == nil {
select {
case events <- event:
case <-ctx.Done():
return
}
continue
}
if recvErr == io.EOF {
return
}
if status.Code(recvErr) == codes.Canceled || ctx.Err() != nil {
return
}
select {
case errs <- &GatewayError{Op: "galaxy watch deploy events", Err: recvErr}:
case <-ctx.Done():
}
return
}
}()
return events, errs, nil
}
// Close closes the underlying gRPC connection.
func (c *GalaxyClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout
if timeout == 0 {
timeout = defaultCallTimeout
}
if timeout < 0 {
return ctx, func() {}
}
if deadline, ok := ctx.Deadline(); ok {
timeoutDeadline := time.Now().Add(timeout)
if deadline.Before(timeoutDeadline) {
return ctx, func() {}
}
}
return context.WithTimeout(ctx, timeout)
}
+427
View File
@@ -0,0 +1,427 @@
package mxgateway
import (
"context"
"errors"
"net"
"testing"
"time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestGalaxyTestConnectionAttachesAuthAndReturnsOk(t *testing.T) {
fake := &fakeGalaxyServer{
testReply: &pb.TestConnectionReply{Ok: true},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ok, err := client.TestConnection(context.Background())
if err != nil {
t.Fatalf("TestConnection() error = %v", err)
}
if !ok {
t.Fatalf("TestConnection() ok = false, want true")
}
if got := fake.testAuth; got != "Bearer test-api-key" {
t.Fatalf("authorization metadata = %q, want %q", got, "Bearer test-api-key")
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentForPresentFalse(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: false},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyGetLastDeployTimeReturnsTimestampWhenPresent(t *testing.T) {
want := time.Date(2026, 4, 28, 12, 34, 56, 0, time.UTC)
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{
Present: true,
TimeOfLastDeploy: timestamppb.New(want),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if !present {
t.Fatalf("present = false, want true")
}
if !got.Equal(want) {
t.Fatalf("time = %v, want %v", got, want)
}
}
func TestGalaxyGetLastDeployTimeReturnsAbsentWhenTimestampNil(t *testing.T) {
fake := &fakeGalaxyServer{
deployReply: &pb.GetLastDeployTimeReply{Present: true, TimeOfLastDeploy: nil},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
got, present, err := client.GetLastDeployTime(context.Background())
if err != nil {
t.Fatalf("GetLastDeployTime() error = %v", err)
}
if present {
t.Fatalf("present = true, want false (nil timestamp)")
}
if !got.IsZero() {
t.Fatalf("time = %v, want zero", got)
}
}
func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
fake := &fakeGalaxyServer{
discoverReply: &pb.DiscoverHierarchyReply{
Objects: []*pb.GalaxyObject{
{
GobjectId: 1,
TagName: "TestMachine_001",
ContainedName: "TestMachine_001",
BrowseName: "TestMachine_001",
IsArea: false,
CategoryId: 7,
TemplateChain: []string{"$Object", "$AppObject"},
Attributes: []*pb.GalaxyAttribute{
{
AttributeName: "DownloadPath",
FullTagReference: "TestMachine_001.DownloadPath",
MxDataType: 8,
DataTypeName: "String",
},
},
},
{
GobjectId: 2,
TagName: "TestMachine_002",
ContainedName: "TestMachine_002",
ParentGobjectId: 1,
},
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
objects, err := client.DiscoverHierarchy(context.Background())
if err != nil {
t.Fatalf("DiscoverHierarchy() error = %v", err)
}
if len(objects) != 2 {
t.Fatalf("len(objects) = %d, want 2", len(objects))
}
if objects[0].GetTagName() != "TestMachine_001" {
t.Fatalf("objects[0].TagName = %q", objects[0].GetTagName())
}
if len(objects[0].GetAttributes()) != 1 {
t.Fatalf("len(attributes) = %d, want 1", len(objects[0].GetAttributes()))
}
if objects[0].GetAttributes()[0].GetFullTagReference() != "TestMachine_001.DownloadPath" {
t.Fatalf("FullTagReference = %q", objects[0].GetAttributes()[0].GetFullTagReference())
}
}
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
fake := &fakeGalaxyServer{failTest: true}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
_, err := client.TestConnection(context.Background())
if err == nil {
t.Fatal("TestConnection() error = nil, want error")
}
var gwErr *GatewayError
if !errors.As(err, &gwErr) {
t.Fatalf("error %T does not support errors.As(*GatewayError)", err)
}
if gwErr.Op != "galaxy test connection" {
t.Fatalf("Op = %q, want %q", gwErr.Op, "galaxy test connection")
}
}
func TestGalaxyWatchDeployEventsReceivesEventsInOrder(t *testing.T) {
bootstrap := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
deploy1 := time.Date(2026, 4, 28, 10, 5, 0, 0, time.UTC)
deploy2 := time.Date(2026, 4, 28, 10, 6, 0, 0, time.UTC)
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{
Sequence: 1,
ObservedAt: timestamppb.New(bootstrap),
TimeOfLastDeploy: timestamppb.New(deploy1),
TimeOfLastDeployPresent: true,
ObjectCount: 10,
AttributeCount: 42,
},
{
Sequence: 2,
ObservedAt: timestamppb.New(deploy2),
TimeOfLastDeploy: timestamppb.New(deploy2),
TimeOfLastDeployPresent: true,
ObjectCount: 11,
AttributeCount: 44,
},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
events, errs, err := client.WatchDeployEvents(ctx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
got := make([]*DeployEvent, 0, 2)
loop:
for {
select {
case ev, ok := <-events:
if !ok {
break loop
}
got = append(got, ev)
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout waiting for events; got %d", len(got))
}
}
if len(got) != 2 {
t.Fatalf("len(events) = %d, want 2", len(got))
}
if got[0].GetSequence() != 1 || got[1].GetSequence() != 2 {
t.Fatalf("sequences = [%d,%d], want [1,2]", got[0].GetSequence(), got[1].GetSequence())
}
if !got[0].GetTimeOfLastDeployPresent() {
t.Fatalf("event[0] TimeOfLastDeployPresent = false, want true")
}
if got[0].GetObjectCount() != 10 || got[0].GetAttributeCount() != 42 {
t.Fatalf("event[0] counts = (%d,%d), want (10,42)", got[0].GetObjectCount(), got[0].GetAttributeCount())
}
if !got[0].GetTimeOfLastDeploy().AsTime().Equal(deploy1) {
t.Fatalf("event[0] TimeOfLastDeploy = %v, want %v", got[0].GetTimeOfLastDeploy().AsTime(), deploy1)
}
}
func TestGalaxyWatchDeployEventsForwardsLastSeenDeployTime(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 7},
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
lastSeen := time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC)
events, errs, err := client.WatchDeployEvents(ctx, &lastSeen)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Drain everything.
loop:
for {
select {
case _, ok := <-events:
if !ok {
break loop
}
case errVal := <-errs:
if errVal != nil {
t.Fatalf("error channel: %v", errVal)
}
case <-ctx.Done():
t.Fatalf("timeout draining events")
}
}
if fake.watchRequest == nil {
t.Fatalf("server did not receive a request")
}
gotTs := fake.watchRequest.GetLastSeenDeployTime()
if gotTs == nil {
t.Fatalf("LastSeenDeployTime = nil, want %v", lastSeen)
}
if !gotTs.AsTime().Equal(lastSeen) {
t.Fatalf("LastSeenDeployTime = %v, want %v", gotTs.AsTime(), lastSeen)
}
}
func TestGalaxyWatchDeployEventsCancelTearsDownStream(t *testing.T) {
fake := &fakeGalaxyServer{
watchEvents: []*pb.DeployEvent{
{Sequence: 1},
},
watchHoldOpen: true,
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
streamCtx, cancelStream := context.WithCancel(context.Background())
events, errs, err := client.WatchDeployEvents(streamCtx, nil)
if err != nil {
t.Fatalf("WatchDeployEvents() error = %v", err)
}
// Wait for the bootstrap event to arrive.
select {
case ev, ok := <-events:
if !ok {
t.Fatalf("events channel closed before delivering bootstrap")
}
if ev.GetSequence() != 1 {
t.Fatalf("got seq=%d, want 1", ev.GetSequence())
}
case <-time.After(2 * time.Second):
t.Fatalf("timeout waiting for bootstrap event")
}
// Cancel the stream; both channels must close cleanly without delivering an error.
cancelStream()
deadline := time.After(2 * time.Second)
for events != nil || errs != nil {
select {
case _, ok := <-events:
if !ok {
events = nil
}
case errVal, ok := <-errs:
if !ok {
errs = nil
continue
}
if errVal != nil {
t.Fatalf("error after cancel: %v", errVal)
}
case <-deadline:
t.Fatalf("channels did not close after cancel; events nil=%v errs nil=%v", events == nil, errs == nil)
}
}
}
func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient, func()) {
t.Helper()
listener := bufconn.Listen(bufSize)
server := grpc.NewServer()
pb.RegisterGalaxyRepositoryServer(server, fake)
go func() {
if err := server.Serve(listener); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
t.Errorf("bufconn server failed: %v", err)
}
}()
dialer := func(ctx context.Context, _ string) (net.Conn, error) {
return listener.DialContext(ctx)
}
client, err := DialGalaxy(context.Background(), Options{
Endpoint: "bufnet",
APIKey: "test-api-key",
Plaintext: true,
DialOptions: []grpc.DialOption{
grpc.WithContextDialer(dialer),
},
})
if err != nil {
t.Fatalf("DialGalaxy() error = %v", err)
}
return client, func() {
client.Close()
server.Stop()
listener.Close()
}
}
type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply
testAuth string
failTest bool
deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration
watchHoldOpen bool
}
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
s.testAuth = authorizationFromContext(ctx)
if s.failTest {
return nil, errors.New("simulated failure")
}
if s.testReply != nil {
return s.testReply, nil
}
return &pb.TestConnectionReply{Ok: true}, nil
}
func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLastDeployTimeRequest) (*pb.GetLastDeployTimeReply, error) {
if s.deployReply != nil {
return s.deployReply, nil
}
return &pb.GetLastDeployTimeReply{Present: false}, nil
}
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
if s.discoverReply != nil {
return s.discoverReply, nil
}
return &pb.DiscoverHierarchyReply{}, nil
}
func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, stream grpc.ServerStreamingServer[pb.DeployEvent]) error {
s.watchRequest = req
for _, event := range s.watchEvents {
if err := stream.Send(event); err != nil {
return err
}
if s.watchSendInterval > 0 {
select {
case <-time.After(s.watchSendInterval):
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
if s.watchHoldOpen {
<-stream.Context().Done()
}
return nil
}
+93
View File
@@ -67,6 +67,99 @@ cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA.
## Galaxy Repository Browse
The Galaxy Repository service is a separate metadata-only gRPC service exposed
by the gateway. It lets clients enumerate the deployed Galaxy object hierarchy
and the dynamic attributes on each object so they know which tag references to
subscribe to via the MXAccess Gateway service. It uses the same API-key auth as
the gateway and requires the `metadata:read` scope.
`GalaxyRepositoryClient` mirrors the `MxGatewayClient` pattern (caller-managed
or owned channel, `MxGatewayClientOptions`, blocking + async variants). Three
RPCs are exposed:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
boolean ok = galaxy.testConnection();
Optional<Instant> lastDeploy = galaxy.getLastDeployTime();
List<GalaxyObject> hierarchy = galaxy.discoverHierarchy();
}
```
`getLastDeployTime` returns `Optional.empty()` when the server reports
`present=false`. `discoverHierarchy` returns the generated `GalaxyObject` proto
messages directly so callers can read all fields (including the nested
`GalaxyAttribute` list) without an extra DTO layer.
The CLI exposes matching subcommands: `galaxy-test`, `galaxy-deploy-time`,
`galaxy-discover`, and `galaxy-watch`. They take the same `--endpoint`,
`--api-key-env`, `--plaintext`, `--ca-file`, `--server-name-override`,
`--timeout`, and `--json` options as the gateway commands.
```powershell
gradle :mxgateway-cli:run --args="galaxy-test --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-deploy-time --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
```
### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
sends a bootstrap `DeployEvent` immediately on subscribe and then one event
each time it observes a new `galaxy.time_of_last_deploy`. The `sequence` field
is monotonic per server start; gaps mean the per-subscriber buffer dropped
older events because the consumer was too slow.
The client exposes both an iterator-style adaptor over the async stub and an
observer-callback variant. Both honour the channel-level `streamTimeout`.
```java
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options);
DeployEventStream events = galaxy.watchDeployEvents(/* lastSeenDeployTime */ null)) {
while (events.hasNext()) {
DeployEvent event = events.next();
// event.getSequence(), event.getObservedAt(),
// event.getTimeOfLastDeploy() / getTimeOfLastDeployPresent(),
// event.getObjectCount(), event.getAttributeCount()
}
}
```
Pass an `Instant` for `lastSeenDeployTime` to suppress the bootstrap event when
the cached deploy time matches what the caller already has. `DeployEventStream`
implements `Iterator<DeployEvent>` and `AutoCloseable`; closing it cancels the
underlying gRPC call.
For callback delivery (e.g. when the consumer wants to drive a queue or
reactive pipeline), use the async variant:
```java
DeployEventSubscription subscription = galaxy.watchDeployEventsAsync(
lastSeen,
new StreamObserver<>() {
@Override public void onNext(DeployEvent value) { /* ... */ }
@Override public void onError(Throwable t) { /* ... */ }
@Override public void onCompleted() { /* ... */ }
});
// later:
subscription.cancel(); // or subscription.close()
```
The matching CLI subcommand streams events until cancelled (Ctrl+C) and prints
one line per event in text mode or one JSON object per event with `--json`:
```powershell
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
gradle :mxgateway-cli:run --args="galaxy-watch --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --last-seen-deploy-time 2026-04-28T18:30:00Z --limit 5"
```
## CLI Usage
Run the CLI through Gradle:
@@ -1,5 +1,7 @@
package com.dohertylan.mxgateway.cli;
import com.dohertylan.mxgateway.client.DeployEventStream;
import com.dohertylan.mxgateway.client.GalaxyRepositoryClient;
import com.dohertylan.mxgateway.client.MxEventStream;
import com.dohertylan.mxgateway.client.MxGatewayClient;
import com.dohertylan.mxgateway.client.MxGatewayClientOptions;
@@ -7,15 +9,21 @@ import com.dohertylan.mxgateway.client.MxGatewayClientVersion;
import com.dohertylan.mxgateway.client.MxGatewaySecrets;
import com.dohertylan.mxgateway.client.MxGatewaySession;
import com.dohertylan.mxgateway.client.MxValues;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import com.google.protobuf.Message;
import com.google.protobuf.util.JsonFormat;
import java.io.PrintWriter;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
@@ -83,9 +91,195 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("write", new WriteCommand(clientFactory));
commandLine.addSubcommand("stream-events", new StreamEventsCommand(clientFactory));
commandLine.addSubcommand("smoke", new SmokeCommand(clientFactory));
commandLine.addSubcommand("galaxy-test", new GalaxyTestConnectionCommand());
commandLine.addSubcommand("galaxy-deploy-time", new GalaxyDeployTimeCommand());
commandLine.addSubcommand("galaxy-discover", new GalaxyDiscoverCommand());
commandLine.addSubcommand("galaxy-watch", new GalaxyWatchCommand());
return commandLine;
}
abstract static class GalaxyCommand implements Callable<Integer> {
@Mixin
CommonOptions common = new CommonOptions();
@Option(names = "--json", description = "Write JSON output.")
boolean json;
GalaxyRepositoryClient connect() {
return GalaxyRepositoryClient.connect(common.resolved().toClientOptions());
}
}
@Command(name = "galaxy-test", description = "Calls GalaxyRepository.TestConnection.")
static final class GalaxyTestConnectionCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
boolean ok = client.testConnection();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-test");
output.put("options", common.redactedJsonMap());
output.put("ok", ok);
out.println(jsonObject(output));
} else {
out.println(ok);
}
}
return 0;
}
}
@Command(name = "galaxy-deploy-time", description = "Calls GalaxyRepository.GetLastDeployTime.")
static final class GalaxyDeployTimeCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
Optional<Instant> result = client.getLastDeployTime();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-deploy-time");
output.put("options", common.redactedJsonMap());
output.put("present", result.isPresent());
output.put("timeOfLastDeploy", result.map(Instant::toString).orElse(""));
out.println(jsonObject(output));
} else if (result.isPresent()) {
out.println(result.get());
} else {
out.println("(none)");
}
}
return 0;
}
}
@Command(name = "galaxy-discover", description = "Calls GalaxyRepository.DiscoverHierarchy.")
static final class GalaxyDiscoverCommand extends GalaxyCommand {
@Override
public Integer call() {
try (GalaxyRepositoryClient client = connect()) {
List<GalaxyObject> objects = client.discoverHierarchy();
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "galaxy-discover");
output.put("options", common.redactedJsonMap());
output.put("objects", objects.stream().map(MxGatewayCli::galaxyObjectMap).toList());
out.println(jsonObject(output));
} else {
out.printf("count=%d%n", objects.size());
for (GalaxyObject obj : objects) {
out.printf(" %s [%s] attrs=%d%n",
obj.getTagName(), obj.getBrowseName(), obj.getAttributesCount());
}
}
}
return 0;
}
}
@Command(
name = "galaxy-watch",
description = "Streams GalaxyRepository.WatchDeployEvents until cancelled.")
static final class GalaxyWatchCommand extends GalaxyCommand {
@Option(
names = "--last-seen-deploy-time",
description =
"Optional ISO-8601 instant. When supplied, the bootstrap event is suppressed if the cached"
+ " deploy time matches.")
String lastSeenDeployTime;
@Option(names = "--limit", defaultValue = "0", description = "Maximum events to print before exiting.")
int limit;
@Override
public Integer call() {
Instant after = parseInstant(lastSeenDeployTime);
try (GalaxyRepositoryClient client = connect();
DeployEventStream events = client.watchDeployEvents(after)) {
PrintWriter out = common.spec.commandLine().getOut();
Thread shutdownHook = new Thread(events::close, "galaxy-watch-shutdown");
Runtime.getRuntime().addShutdownHook(shutdownHook);
try {
int count = 0;
while (events.hasNext()) {
DeployEvent event = events.next();
if (json) {
out.println(protoJson(event));
} else {
out.printf(
"seq=%d observed=%s deployTime=%s objects=%d attributes=%d%n",
event.getSequence(),
formatTimestamp(event.getObservedAt()),
event.getTimeOfLastDeployPresent()
? formatTimestamp(event.getTimeOfLastDeploy())
: "(none)",
event.getObjectCount(),
event.getAttributeCount());
}
out.flush();
count++;
if (limit > 0 && count >= limit) {
events.close();
break;
}
}
} finally {
try {
Runtime.getRuntime().removeShutdownHook(shutdownHook);
} catch (IllegalStateException ignored) {
// JVM is already shutting down.
}
}
}
return 0;
}
private static Instant parseInstant(String value) {
if (value == null || value.isBlank()) {
return null;
}
return Instant.parse(value);
}
private static String formatTimestamp(com.google.protobuf.Timestamp ts) {
return Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()).toString();
}
}
private static Map<String, Object> galaxyObjectMap(GalaxyObject obj) {
Map<String, Object> values = new LinkedHashMap<>();
values.put("gobjectId", obj.getGobjectId());
values.put("tagName", obj.getTagName());
values.put("containedName", obj.getContainedName());
values.put("browseName", obj.getBrowseName());
values.put("parentGobjectId", obj.getParentGobjectId());
values.put("isArea", obj.getIsArea());
values.put("categoryId", obj.getCategoryId());
values.put("hostedByGobjectId", obj.getHostedByGobjectId());
values.put("templateChain", new ArrayList<>(obj.getTemplateChainList()));
List<Map<String, Object>> attrs = new ArrayList<>();
for (GalaxyAttribute attr : obj.getAttributesList()) {
Map<String, Object> attrMap = new LinkedHashMap<>();
attrMap.put("attributeName", attr.getAttributeName());
attrMap.put("fullTagReference", attr.getFullTagReference());
attrMap.put("mxDataType", attr.getMxDataType());
attrMap.put("dataTypeName", attr.getDataTypeName());
attrMap.put("isArray", attr.getIsArray());
attrMap.put("arrayDimension", attr.getArrayDimension());
attrMap.put("arrayDimensionPresent", attr.getArrayDimensionPresent());
attrMap.put("mxAttributeCategory", attr.getMxAttributeCategory());
attrMap.put("securityClassification", attr.getSecurityClassification());
attrMap.put("isHistorized", attr.getIsHistorized());
attrMap.put("isAlarm", attr.getIsAlarm());
attrs.add(attrMap);
}
values.put("attributes", attrs);
return values;
}
@Command(name = "version", description = "Prints the Java client version.")
public static final class VersionCommand implements Callable<Integer> {
@Spec
@@ -25,6 +25,7 @@ sourceSets {
srcDir rootProject.file('../../src/MxGateway.Contracts/Protos')
include 'mxaccess_gateway.proto'
include 'mxaccess_worker.proto'
include 'galaxy_repository.proto'
}
}
}
@@ -0,0 +1,132 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.Status;
import io.grpc.StatusRuntimeException;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
/**
* Iterator-style adaptor over the {@code WatchDeployEvents} server-streaming
* RPC. Mirrors {@link MxEventStream}: events arrive on a background gRPC thread
* and are buffered in a bounded blocking queue; the iterator drains them.
* Closing the stream cancels the underlying gRPC call.
*/
public final class DeployEventStream implements Iterator<DeployEvent>, AutoCloseable {
private static final Object END = new Object();
private final BlockingQueue<Object> queue;
private volatile ClientCallStreamObserver<WatchDeployEventsRequest> requestStream;
private volatile boolean closed;
private Object next;
DeployEventStream(int capacity) {
queue = new ArrayBlockingQueue<>(capacity);
}
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> observer() {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> requestStream) {
DeployEventStream.this.requestStream = requestStream;
}
@Override
public void onNext(DeployEvent value) {
offer(value);
}
@Override
public void onError(Throwable error) {
if (Status.fromThrowable(error).getCode() == Status.Code.CANCELLED && closed) {
offer(END);
return;
}
offer(error);
}
@Override
public void onCompleted() {
offer(END);
}
};
}
@Override
public boolean hasNext() {
if (next == END) {
return false;
}
if (next == null) {
next = take();
}
if (next instanceof RuntimeException runtimeException) {
next = END;
throw runtimeException;
}
if (next instanceof Throwable throwable) {
next = END;
throw new MxGatewayException(
"galaxy watch deploy events failed: " + throwable.getMessage(), throwable);
}
return next != END;
}
@Override
public DeployEvent next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
Object value = next;
next = null;
return (DeployEvent) value;
}
@Override
public void close() {
closed = true;
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
offer(END);
}
private Object take() {
while (true) {
try {
return queue.take();
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
return new StatusRuntimeException(
Status.CANCELLED.withDescription("interrupted while reading deploy events"));
}
}
}
private void offer(Object value) {
Objects.requireNonNull(value, "value");
if (value == END) {
if (!queue.offer(value)) {
queue.clear();
queue.offer(value);
}
return;
}
if (!queue.offer(value)) {
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream;
if (stream != null) {
stream.cancel("client deploy event stream queue overflowed", null);
}
queue.clear();
queue.offer(new MxGatewayException("galaxy watch deploy events queue overflowed"));
queue.offer(END);
}
}
}
@@ -0,0 +1,60 @@
package com.dohertylan.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.stub.ClientCallStreamObserver;
import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Cancellable handle returned by the async {@code watchDeployEvents} variant.
* Mirrors {@link MxGatewayEventSubscription} but for the Galaxy Repository
* deploy-event stream.
*/
public final class DeployEventSubscription implements AutoCloseable {
private final AtomicReference<ClientCallStreamObserver<WatchDeployEventsRequest>> requestStream =
new AtomicReference<>();
private final AtomicBoolean cancelled = new AtomicBoolean();
ClientResponseObserver<WatchDeployEventsRequest, DeployEvent> wrap(StreamObserver<DeployEvent> observer) {
return new ClientResponseObserver<>() {
@Override
public void beforeStart(ClientCallStreamObserver<WatchDeployEventsRequest> stream) {
requestStream.set(stream);
if (cancelled.get()) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void onNext(DeployEvent value) {
observer.onNext(value);
}
@Override
public void onError(Throwable error) {
observer.onError(error);
}
@Override
public void onCompleted() {
observer.onCompleted();
}
};
}
public void cancel() {
cancelled.set(true);
ClientCallStreamObserver<WatchDeployEventsRequest> stream = requestStream.get();
if (stream != null) {
stream.cancel("client cancelled deploy event stream", null);
}
}
@Override
public void close() {
cancel();
}
}
@@ -0,0 +1,288 @@
package com.dohertylan.mxgateway.client;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import com.google.protobuf.Timestamp;
import io.grpc.Channel;
import io.grpc.ClientInterceptors;
import io.grpc.ManagedChannel;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Instant;
import java.util.Iterator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.SSLException;
/**
* Thin wrapper around the generated {@link GalaxyRepositoryGrpc} stubs that
* exposes the three metadata-only RPCs of the Galaxy Repository service in
* idiomatic Java types. Mirrors the constructor and option-handling style of
* {@link MxGatewayClient}.
*/
public final class GalaxyRepositoryClient implements AutoCloseable {
private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options;
private final GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub blockingStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub futureStub;
private final GalaxyRepositoryGrpc.GalaxyRepositoryStub asyncStub;
private GalaxyRepositoryClient(ManagedChannel channel, MxGatewayClientOptions options) {
this.ownedChannel = channel;
this.options = options;
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/**
* Construct a client over a caller-managed {@link Channel}. The caller owns
* channel lifecycle; {@link #close()} is a no-op for this constructor.
*/
public GalaxyRepositoryClient(Channel channel, MxGatewayClientOptions options) {
this.ownedChannel = null;
this.options = Objects.requireNonNull(options, "options");
Channel intercepted = ClientInterceptors.intercept(channel, new MxGatewayAuthInterceptor(options.apiKey()));
blockingStub = GalaxyRepositoryGrpc.newBlockingStub(intercepted);
futureStub = GalaxyRepositoryGrpc.newFutureStub(intercepted);
asyncStub = GalaxyRepositoryGrpc.newStub(intercepted);
}
/** Build a new client and own its channel; close shuts the channel down. */
public static GalaxyRepositoryClient connect(MxGatewayClientOptions options) {
return new GalaxyRepositoryClient(createChannel(options), options);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryBlockingStub rawBlockingStub() {
return withDeadline(blockingStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryFutureStub rawFutureStub() {
return withDeadline(futureStub);
}
public GalaxyRepositoryGrpc.GalaxyRepositoryStub rawAsyncStub() {
return asyncStub;
}
/**
* Invoke the {@code TestConnection} RPC and return the {@code ok} flag.
*/
public boolean testConnection() {
try {
TestConnectionReply reply = rawBlockingStub().testConnection(TestConnectionRequest.getDefaultInstance());
return reply.getOk();
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy test connection", error);
}
}
public CompletableFuture<Boolean> testConnectionAsync() {
return toCompletable(rawFutureStub().testConnection(TestConnectionRequest.getDefaultInstance()))
.thenApply(TestConnectionReply::getOk);
}
/**
* Invoke the {@code GetLastDeployTime} RPC. Returns {@link Optional#empty()}
* when the server reports {@code present=false}.
*/
public Optional<Instant> getLastDeployTime() {
try {
GetLastDeployTimeReply reply =
rawBlockingStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance());
return mapDeployTime(reply);
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy get last deploy time", error);
}
}
public CompletableFuture<Optional<Instant>> getLastDeployTimeAsync() {
return toCompletable(rawFutureStub().getLastDeployTime(GetLastDeployTimeRequest.getDefaultInstance()))
.thenApply(GalaxyRepositoryClient::mapDeployTime);
}
/**
* Invoke the {@code DiscoverHierarchy} RPC and return the generated
* {@link GalaxyObject} messages directly. Callers can read every field of
* the proto message without an extra DTO layer.
*/
public List<GalaxyObject> discoverHierarchy() {
try {
DiscoverHierarchyReply reply =
rawBlockingStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance());
return reply.getObjectsList();
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy discover hierarchy", error);
}
}
public CompletableFuture<List<GalaxyObject>> discoverHierarchyAsync() {
return toCompletable(rawFutureStub().discoverHierarchy(DiscoverHierarchyRequest.getDefaultInstance()))
.thenApply(DiscoverHierarchyReply::getObjectsList);
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub and consume
* results through a blocking iterator. Closing the returned stream cancels
* the underlying gRPC call.
*
* @param lastSeenDeployTime optional. When non-null, the bootstrap event is
* suppressed if the cached deploy time matches.
*/
public DeployEventStream watchDeployEvents(Instant lastSeenDeployTime) {
DeployEventStream stream = new DeployEventStream(16);
withStreamDeadline(rawAsyncStub()).watchDeployEvents(buildWatchRequest(lastSeenDeployTime), stream.observer());
return stream;
}
/**
* Iterator-style alias for {@link #watchDeployEvents(Instant)} matching the
* task-spec signature.
*/
public Iterator<DeployEvent> watchDeployEventsIterator(Instant lastSeenDeployTime) {
return watchDeployEvents(lastSeenDeployTime);
}
/**
* Subscribe to {@code WatchDeployEvents} via the async stub, dispatching
* each event to {@code observer}. The returned subscription is cancellable
* and {@link AutoCloseable}.
*/
public DeployEventSubscription watchDeployEventsAsync(
Instant lastSeenDeployTime, StreamObserver<DeployEvent> observer) {
Objects.requireNonNull(observer, "observer");
DeployEventSubscription subscription = new DeployEventSubscription();
withStreamDeadline(rawAsyncStub())
.watchDeployEvents(buildWatchRequest(lastSeenDeployTime), subscription.wrap(observer));
return subscription;
}
private static WatchDeployEventsRequest buildWatchRequest(Instant lastSeenDeployTime) {
WatchDeployEventsRequest.Builder builder = WatchDeployEventsRequest.newBuilder();
if (lastSeenDeployTime != null) {
builder.setLastSeenDeployTime(Timestamp.newBuilder()
.setSeconds(lastSeenDeployTime.getEpochSecond())
.setNanos(lastSeenDeployTime.getNano())
.build());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withStreamDeadline(T stub) {
if (options.streamTimeout() == null || options.streamTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.streamTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
@Override
public void close() {
if (ownedChannel != null) {
ownedChannel.shutdown();
}
}
public void closeAndAwaitTermination() throws InterruptedException {
if (ownedChannel != null) {
ownedChannel.shutdown();
if (!ownedChannel.awaitTermination(options.connectTimeout().toMillis(), TimeUnit.MILLISECONDS)) {
ownedChannel.shutdownNow();
}
}
}
private static Optional<Instant> mapDeployTime(GetLastDeployTimeReply reply) {
if (!reply.getPresent()) {
return Optional.empty();
}
Timestamp ts = reply.getTimeOfLastDeploy();
return Optional.of(Instant.ofEpochSecond(ts.getSeconds(), ts.getNanos()));
}
private static ManagedChannel createChannel(MxGatewayClientOptions options) {
NettyChannelBuilder builder = NettyChannelBuilder.forTarget(options.endpoint())
.maxInboundMessageSize(16 * 1024 * 1024);
if (!options.connectTimeout().isNegative()) {
builder.withOption(
io.grpc.netty.shaded.io.netty.channel.ChannelOption.CONNECT_TIMEOUT_MILLIS,
Math.toIntExact(options.connectTimeout().toMillis()));
}
if (options.plaintext()) {
builder.usePlaintext();
} else if (options.caCertificatePath() != null) {
try {
builder.sslContext(GrpcSslContexts.forClient()
.trustManager(options.caCertificatePath().toFile())
.build());
} catch (SSLException error) {
throw new MxGatewayException("failed to configure galaxy repository TLS", error);
}
} else {
builder.useTransportSecurity();
}
if (!options.serverNameOverride().isBlank()) {
builder.overrideAuthority(options.serverNameOverride());
}
return builder.build();
}
private <T extends io.grpc.stub.AbstractStub<T>> T withDeadline(T stub) {
if (options.callTimeout().isNegative()) {
return stub;
}
return stub.withDeadlineAfter(options.callTimeout().toNanos(), TimeUnit.NANOSECONDS);
}
private static <T> CompletableFuture<T> toCompletable(com.google.common.util.concurrent.ListenableFuture<T> source) {
CompletableFuture<T> target = new CompletableFuture<>();
Futures.addCallback(
source,
new FutureCallback<>() {
@Override
public void onSuccess(T result) {
target.complete(result);
}
@Override
public void onFailure(Throwable error) {
if (error instanceof RuntimeException runtimeException) {
target.completeExceptionally(MxGatewayErrors.fromGrpc("galaxy async call", runtimeException));
return;
}
target.completeExceptionally(error);
}
},
MoreExecutors.directExecutor());
target.whenComplete((ignoredResult, ignoredError) -> {
if (target.isCancelled()) {
source.cancel(true);
}
});
return target;
}
}
@@ -0,0 +1,327 @@
package com.dohertylan.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.Timestamp;
import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyAttribute;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest;
import io.grpc.ManagedChannel;
import io.grpc.Metadata;
import io.grpc.Server;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.StreamObserver;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
final class GalaxyRepositoryClientTests {
@Test
void testConnectionReturnsOkAndSendsAuthMetadata() throws Exception {
AtomicReference<String> authorization = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, authorization);
GalaxyRepositoryClient client = g.client("mxgw_galaxy_secret")) {
assertTrue(client.testConnection());
assertEquals("Bearer mxgw_galaxy_secret", authorization.get());
}
}
@Test
void getLastDeployTimeReturnsEmptyWhenPresentFalse() throws Exception {
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(
GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertFalse(result.isPresent());
}
}
@Test
void getLastDeployTimeReturnsInstantWhenPresent() throws Exception {
Timestamp expected = Timestamp.newBuilder().setSeconds(1_700_000_000L).setNanos(123_000_000).build();
TestService service = new TestService() {
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder()
.setPresent(true)
.setTimeOfLastDeploy(expected)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
Optional<Instant> result = client.getLastDeployTime();
assertTrue(result.isPresent());
assertEquals(Instant.ofEpochSecond(1_700_000_000L, 123_000_000), result.get());
}
}
@Test
void discoverHierarchyReturnsObjectsAndAttributes() throws Exception {
AtomicReference<DiscoverHierarchyRequest> seenRequest = new AtomicReference<>();
TestService service = new TestService() {
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
seenRequest.set(request);
responseObserver.onNext(DiscoverHierarchyReply.newBuilder()
.addObjects(GalaxyObject.newBuilder()
.setGobjectId(7)
.setTagName("Pump_001")
.setContainedName("Pump")
.setBrowseName("Pump")
.setParentGobjectId(1)
.setIsArea(false)
.setCategoryId(3)
.setHostedByGobjectId(0)
.addTemplateChain("$Pump")
.addAttributes(GalaxyAttribute.newBuilder()
.setAttributeName("Speed")
.setFullTagReference("Pump_001.Speed")
.setMxDataType(5)
.setDataTypeName("MxFloat")
.setIsArray(false)
.setIsHistorized(true)))
.build());
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<GalaxyObject> objects = client.discoverHierarchy();
assertEquals(1, objects.size());
GalaxyObject only = objects.get(0);
assertEquals(7, only.getGobjectId());
assertEquals("Pump_001", only.getTagName());
assertEquals(1, only.getAttributesCount());
assertEquals("Pump_001.Speed", only.getAttributes(0).getFullTagReference());
assertTrue(only.getAttributes(0).getIsHistorized());
}
}
@Test
void watchDeployEventsReceivesEventsInOrder() throws Exception {
DeployEvent first = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_000L).build())
.setTimeOfLastDeploy(Timestamp.newBuilder().setSeconds(1_699_999_000L).build())
.setTimeOfLastDeployPresent(true)
.setObjectCount(42)
.setAttributeCount(123)
.build();
DeployEvent second = DeployEvent.newBuilder()
.setSequence(2)
.setObservedAt(Timestamp.newBuilder().setSeconds(1_700_000_100L).build())
.setTimeOfLastDeployPresent(false)
.setObjectCount(43)
.setAttributeCount(125)
.build();
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onNext(first);
responseObserver.onNext(second);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(null)) {
assertTrue(stream.hasNext());
DeployEvent event1 = stream.next();
assertEquals(1L, event1.getSequence());
assertEquals(42, event1.getObjectCount());
assertTrue(event1.getTimeOfLastDeployPresent());
assertTrue(stream.hasNext());
DeployEvent event2 = stream.next();
assertEquals(2L, event2.getSequence());
assertFalse(event2.getTimeOfLastDeployPresent());
assertFalse(stream.hasNext());
}
}
}
@Test
void watchDeployEventsPropagatesLastSeenDeployTime() throws Exception {
AtomicReference<WatchDeployEventsRequest> seen = new AtomicReference<>();
Instant lastSeen = Instant.ofEpochSecond(1_700_000_000L, 250_000_000);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
seen.set(request);
responseObserver.onCompleted();
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
try (DeployEventStream stream = client.watchDeployEvents(lastSeen)) {
assertFalse(stream.hasNext());
}
}
WatchDeployEventsRequest request = seen.get();
assertNotNull(request);
Timestamp expected = request.getLastSeenDeployTime();
assertEquals(lastSeen.getEpochSecond(), expected.getSeconds());
assertEquals(lastSeen.getNano(), expected.getNanos());
}
@Test
void watchDeployEventsClientCancellationTearsDownCleanly() throws Exception {
CountDownLatch cancelObserved = new CountDownLatch(1);
TestService service = new TestService() {
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
io.grpc.stub.ServerCallStreamObserver<DeployEvent> serverObserver =
(io.grpc.stub.ServerCallStreamObserver<DeployEvent>) responseObserver;
serverObserver.setOnCancelHandler(cancelObserved::countDown);
DeployEvent bootstrap = DeployEvent.newBuilder()
.setSequence(1)
.setObservedAt(Timestamp.newBuilder().setSeconds(1L).build())
.build();
responseObserver.onNext(bootstrap);
// Server holds the stream open; cancellation must come from the client.
}
};
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
DeployEventStream stream = client.watchDeployEvents(null);
assertTrue(stream.hasNext());
assertEquals(1L, stream.next().getSequence());
stream.close();
assertTrue(
cancelObserved.await(5, TimeUnit.SECONDS),
"server should observe client-side cancellation");
assertFalse(stream.hasNext());
}
}
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
@Override
public void testConnection(
TestConnectionRequest request, StreamObserver<TestConnectionReply> responseObserver) {
responseObserver.onNext(TestConnectionReply.newBuilder().setOk(true).build());
responseObserver.onCompleted();
}
@Override
public void getLastDeployTime(
GetLastDeployTimeRequest request, StreamObserver<GetLastDeployTimeReply> responseObserver) {
responseObserver.onNext(GetLastDeployTimeReply.newBuilder().setPresent(false).build());
responseObserver.onCompleted();
}
@Override
public void discoverHierarchy(
DiscoverHierarchyRequest request, StreamObserver<DiscoverHierarchyReply> responseObserver) {
responseObserver.onNext(DiscoverHierarchyReply.getDefaultInstance());
responseObserver.onCompleted();
}
@Override
public void watchDeployEvents(
WatchDeployEventsRequest request, StreamObserver<DeployEvent> responseObserver) {
responseObserver.onCompleted();
}
}
private record InProcessGalaxy(Server server, ManagedChannel channel) implements AutoCloseable {
static InProcessGalaxy start(
GalaxyRepositoryGrpc.GalaxyRepositoryImplBase service, AtomicReference<String> authorization)
throws Exception {
String serverName = "mxgw-galaxy-java-" + UUID.randomUUID();
ServerInterceptor interceptor = new ServerInterceptor() {
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCall<ReqT, RespT> call,
Metadata headers,
ServerCallHandler<ReqT, RespT> next) {
authorization.set(headers.get(MxGatewayAuthInterceptor.AUTHORIZATION_HEADER));
return next.startCall(call, headers);
}
};
Server server = InProcessServerBuilder.forName(serverName)
.directExecutor()
.addService(io.grpc.ServerInterceptors.intercept(service, interceptor))
.build()
.start();
ManagedChannel channel = InProcessChannelBuilder.forName(serverName)
.directExecutor()
.build();
return new InProcessGalaxy(server, channel);
}
GalaxyRepositoryClient client(String apiKey) {
return new GalaxyRepositoryClient(
channel,
MxGatewayClientOptions.builder()
.endpoint("in-process")
.apiKey(apiKey)
.plaintext(true)
.callTimeout(Duration.ofSeconds(5))
.build());
}
@Override
public void close() {
channel.shutdownNow();
server.shutdownNow();
}
}
}
@@ -0,0 +1,641 @@
package galaxy_repository.v1;
import static io.grpc.MethodDescriptor.generateFullMethodName;
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
@io.grpc.stub.annotations.GrpcGenerated
public final class GalaxyRepositoryGrpc {
private GalaxyRepositoryGrpc() {}
public static final java.lang.String SERVICE_NAME = "galaxy_repository.v1.GalaxyRepository";
// Static method descriptors that strictly reflect the proto.
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "TestConnection",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> getTestConnectionMethod;
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getTestConnectionMethod = GalaxyRepositoryGrpc.getTestConnectionMethod) == null) {
GalaxyRepositoryGrpc.getTestConnectionMethod = getTestConnectionMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "TestConnection"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("TestConnection"))
.build();
}
}
}
return getTestConnectionMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "GetLastDeployTime",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getGetLastDeployTimeMethod;
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getGetLastDeployTimeMethod = GalaxyRepositoryGrpc.getGetLastDeployTimeMethod) == null) {
GalaxyRepositoryGrpc.getGetLastDeployTimeMethod = getGetLastDeployTimeMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "GetLastDeployTime"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("GetLastDeployTime"))
.build();
}
}
}
return getGetLastDeployTimeMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "DiscoverHierarchy",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> getDiscoverHierarchyMethod;
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getDiscoverHierarchyMethod = GalaxyRepositoryGrpc.getDiscoverHierarchyMethod) == null) {
GalaxyRepositoryGrpc.getDiscoverHierarchyMethod = getDiscoverHierarchyMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "DiscoverHierarchy"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("DiscoverHierarchy"))
.build();
}
}
}
return getDiscoverHierarchyMethod;
}
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "WatchDeployEvents",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.class,
methodType = io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> getWatchDeployEventsMethod;
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getWatchDeployEventsMethod = GalaxyRepositoryGrpc.getWatchDeployEventsMethod) == null) {
GalaxyRepositoryGrpc.getWatchDeployEventsMethod = getWatchDeployEventsMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.SERVER_STREAMING)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "WatchDeployEvents"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("WatchDeployEvents"))
.build();
}
}
}
return getWatchDeployEventsMethod;
}
/**
* Creates a new async stub that supports all call types for the service
*/
public static GalaxyRepositoryStub newStub(io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryStub>() {
@java.lang.Override
public GalaxyRepositoryStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
};
return GalaxyRepositoryStub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports all types of calls on the service
*/
public static GalaxyRepositoryBlockingV2Stub newBlockingV2Stub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingV2Stub>() {
@java.lang.Override
public GalaxyRepositoryBlockingV2Stub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingV2Stub.newStub(factory, channel);
}
/**
* Creates a new blocking-style stub that supports unary and streaming output calls on the service
*/
public static GalaxyRepositoryBlockingStub newBlockingStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryBlockingStub>() {
@java.lang.Override
public GalaxyRepositoryBlockingStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
};
return GalaxyRepositoryBlockingStub.newStub(factory, channel);
}
/**
* Creates a new ListenableFuture-style stub that supports unary calls on the service
*/
public static GalaxyRepositoryFutureStub newFutureStub(
io.grpc.Channel channel) {
io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub> factory =
new io.grpc.stub.AbstractStub.StubFactory<GalaxyRepositoryFutureStub>() {
@java.lang.Override
public GalaxyRepositoryFutureStub newStub(io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
};
return GalaxyRepositoryFutureStub.newStub(factory, channel);
}
/**
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public interface AsyncService {
/**
*/
default void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getTestConnectionMethod(), responseObserver);
}
/**
*/
default void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getGetLastDeployTimeMethod(), responseObserver);
}
/**
*/
default void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getDiscoverHierarchyMethod(), responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
default void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
}
}
/**
* Base class for the server implementation of the service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static abstract class GalaxyRepositoryImplBase
implements io.grpc.BindableService, AsyncService {
@java.lang.Override public final io.grpc.ServerServiceDefinition bindService() {
return GalaxyRepositoryGrpc.bindService(this);
}
}
/**
* A stub to allow clients to do asynchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryStub
extends io.grpc.stub.AbstractAsyncStub<GalaxyRepositoryStub> {
private GalaxyRepositoryStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryStub(channel, callOptions);
}
/**
*/
public void testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request, responseObserver);
}
/**
*/
public void discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request, responseObserver);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public void watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
}
}
/**
* A stub to allow clients to do synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingV2Stub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingV2Stub> {
private GalaxyRepositoryBlockingV2Stub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingV2Stub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingV2Stub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
@io.grpc.ExperimentalApi("https://github.com/grpc/grpc-java/issues/10918")
public io.grpc.stub.BlockingClientCall<?, galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>
watchDeployEvents(galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do limited synchronous rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryBlockingStub
extends io.grpc.stub.AbstractBlockingStub<GalaxyRepositoryBlockingStub> {
private GalaxyRepositoryBlockingStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryBlockingStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryBlockingStub(channel, callOptions);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply testConnection(galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getTestConnectionMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply getLastDeployTime(galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getGetLastDeployTimeMethod(), getCallOptions(), request);
}
/**
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply discoverHierarchy(galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getDiscoverHierarchyMethod(), getCallOptions(), request);
}
/**
* <pre>
* Server-stream of deploy events. The server emits the current state immediately
* on subscribe (so clients can bootstrap their cache without waiting for the next
* deploy), then emits one event each time the gateway's hierarchy cache observes
* a new galaxy.time_of_last_deploy. The sequence field is monotonically
* increasing per server start; gaps indicate the per-subscriber buffer dropped
* older events because the client was too slow.
* </pre>
*/
public java.util.Iterator<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> watchDeployEvents(
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest request) {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
}
}
/**
* A stub to allow clients to do ListenableFuture-style rpc calls to service GalaxyRepository.
* <pre>
* Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
* database). Lets clients enumerate the deployed object hierarchy and each
* object's dynamic attributes so they know what tag references to subscribe
* to via the MxAccessGateway service.
* </pre>
*/
public static final class GalaxyRepositoryFutureStub
extends io.grpc.stub.AbstractFutureStub<GalaxyRepositoryFutureStub> {
private GalaxyRepositoryFutureStub(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
super(channel, callOptions);
}
@java.lang.Override
protected GalaxyRepositoryFutureStub build(
io.grpc.Channel channel, io.grpc.CallOptions callOptions) {
return new GalaxyRepositoryFutureStub(channel, callOptions);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply> testConnection(
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getTestConnectionMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply> getLastDeployTime(
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getGetLastDeployTimeMethod(), getCallOptions()), request);
}
/**
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply> discoverHierarchy(
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
}
}
private static final int METHODID_TEST_CONNECTION = 0;
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
private static final int METHODID_DISCOVER_HIERARCHY = 2;
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ServerStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.ClientStreamingMethod<Req, Resp>,
io.grpc.stub.ServerCalls.BidiStreamingMethod<Req, Resp> {
private final AsyncService serviceImpl;
private final int methodId;
MethodHandlers(AsyncService serviceImpl, int methodId) {
this.serviceImpl = serviceImpl;
this.methodId = methodId;
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public void invoke(Req request, io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
case METHODID_TEST_CONNECTION:
serviceImpl.testConnection((galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>) responseObserver);
break;
case METHODID_GET_LAST_DEPLOY_TIME:
serviceImpl.getLastDeployTime((galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>) responseObserver);
break;
case METHODID_DISCOVER_HIERARCHY:
serviceImpl.discoverHierarchy((galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>) responseObserver);
break;
case METHODID_WATCH_DEPLOY_EVENTS:
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
break;
default:
throw new AssertionError();
}
}
@java.lang.Override
@java.lang.SuppressWarnings("unchecked")
public io.grpc.stub.StreamObserver<Req> invoke(
io.grpc.stub.StreamObserver<Resp> responseObserver) {
switch (methodId) {
default:
throw new AssertionError();
}
}
}
public static final io.grpc.ServerServiceDefinition bindService(AsyncService service) {
return io.grpc.ServerServiceDefinition.builder(getServiceDescriptor())
.addMethod(
getTestConnectionMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.TestConnectionReply>(
service, METHODID_TEST_CONNECTION)))
.addMethod(
getGetLastDeployTimeMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.GetLastDeployTimeReply>(
service, METHODID_GET_LAST_DEPLOY_TIME)))
.addMethod(
getDiscoverHierarchyMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply>(
service, METHODID_DISCOVER_HIERARCHY)))
.addMethod(
getWatchDeployEventsMethod(),
io.grpc.stub.ServerCalls.asyncServerStreamingCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
service, METHODID_WATCH_DEPLOY_EVENTS)))
.build();
}
private static abstract class GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoFileDescriptorSupplier, io.grpc.protobuf.ProtoServiceDescriptorSupplier {
GalaxyRepositoryBaseDescriptorSupplier() {}
@java.lang.Override
public com.google.protobuf.Descriptors.FileDescriptor getFileDescriptor() {
return galaxy_repository.v1.GalaxyRepositoryOuterClass.getDescriptor();
}
@java.lang.Override
public com.google.protobuf.Descriptors.ServiceDescriptor getServiceDescriptor() {
return getFileDescriptor().findServiceByName("GalaxyRepository");
}
}
private static final class GalaxyRepositoryFileDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier {
GalaxyRepositoryFileDescriptorSupplier() {}
}
private static final class GalaxyRepositoryMethodDescriptorSupplier
extends GalaxyRepositoryBaseDescriptorSupplier
implements io.grpc.protobuf.ProtoMethodDescriptorSupplier {
private final java.lang.String methodName;
GalaxyRepositoryMethodDescriptorSupplier(java.lang.String methodName) {
this.methodName = methodName;
}
@java.lang.Override
public com.google.protobuf.Descriptors.MethodDescriptor getMethodDescriptor() {
return getServiceDescriptor().findMethodByName(methodName);
}
}
private static volatile io.grpc.ServiceDescriptor serviceDescriptor;
public static io.grpc.ServiceDescriptor getServiceDescriptor() {
io.grpc.ServiceDescriptor result = serviceDescriptor;
if (result == null) {
synchronized (GalaxyRepositoryGrpc.class) {
result = serviceDescriptor;
if (result == null) {
serviceDescriptor = result = io.grpc.ServiceDescriptor.newBuilder(SERVICE_NAME)
.setSchemaDescriptor(new GalaxyRepositoryFileDescriptorSupplier())
.addMethod(getTestConnectionMethod())
.addMethod(getGetLastDeployTimeMethod())
.addMethod(getDiscoverHierarchyMethod())
.addMethod(getWatchDeployEventsMethod())
.build();
}
}
}
return result;
}
}
+4
View File
@@ -12,6 +12,10 @@
{
"path": "mxaccess_worker.proto",
"role": "gateway_worker_ipc"
},
{
"path": "galaxy_repository.proto",
"role": "public_galaxy_repository"
}
],
"descriptorSet": "clients/proto/descriptors/mxaccessgw-client-v1.protoset",
+70
View File
@@ -98,6 +98,76 @@ MXAccess commands and preserve raw replies on typed command exceptions.
Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process.
## Galaxy Repository Browse
The `GalaxyRepositoryClient` wraps the read-only `GalaxyRepository` gRPC
service. It lets callers test connectivity to the AVEVA System Platform
Galaxy Repository (ZB SQL database), read the last deploy timestamp, and
enumerate the deployed object hierarchy plus each object's dynamic
attributes:
```python
from mxgateway import GalaxyRepositoryClient
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
if not await galaxy.test_connection():
raise RuntimeError("gateway cannot reach the Galaxy Repository DB")
last_deploy = await galaxy.get_last_deploy_time()
print(f"last deploy: {last_deploy}")
for obj in await galaxy.discover_hierarchy():
print(obj.tag_name, obj.contained_name)
for attr in obj.attributes:
print(" ", attr.attribute_name, "->", attr.full_tag_reference)
```
The methods return native Python types (`bool`, `datetime | None`, and a
`list[GalaxyObject]` of generated proto messages) so callers can index
into the hierarchy without learning the underlying stub class. The
service requires the `metadata:read` scope on the API key.
### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
subscription that emits the current cached deploy state immediately and
then one `DeployEvent` per new Galaxy deploy. `sequence` is monotonic per
gateway start; gaps mean events were dropped from the per-subscriber
buffer. Pass `last_seen_deploy_time` to suppress the bootstrap event when
the caller already has the current state cached:
```python
from datetime import datetime, timezone
from mxgateway import DeployEvent, GalaxyRepositoryClient
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
last_seen: datetime | None = None
async for event in galaxy.watch_deploy_events(last_seen_deploy_time=last_seen):
assert isinstance(event, DeployEvent)
print(
f"#{event.sequence} deploy={event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)} "
f"objects={event.object_count} attributes={event.attribute_count}"
)
if event.time_of_last_deploy_present:
last_seen = event.time_of_last_deploy.ToDatetime(tzinfo=timezone.utc)
```
The method returns an async iterator yielding the generated `DeployEvent`
proto. Breaking out of the loop, calling `aclose()` on the iterator, or
cancelling the surrounding task closes the underlying gRPC stream
cleanly. The streaming RPC requires the same `metadata:read` scope as
the other Galaxy methods. The CLI does not currently expose a
streaming `watch-deploy-events` subcommand — use the library API
directly when subscribing to deploy events from Python.
## Authentication And TLS
`ClientOptions.api_key` adds this metadata to unary calls and streams:
+2 -1
View File
@@ -19,4 +19,5 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item
"--python_out=$outputRoot" `
"--grpc_python_out=$outputRoot" `
mxaccess_gateway.proto `
mxaccess_worker.proto
mxaccess_worker.proto `
galaxy_repository.proto
+12
View File
@@ -2,6 +2,13 @@
from .auth import ApiKey, auth_metadata
from .client import GatewayClient
from .galaxy import GalaxyRepositoryClient
from .generated.galaxy_repository_pb2 import (
DeployEvent,
GalaxyAttribute,
GalaxyObject,
WatchDeployEventsRequest,
)
from .errors import (
MxAccessError,
MxGatewayAuthenticationError,
@@ -20,6 +27,10 @@ from .version import __version__
__all__ = [
"ApiKey",
"ClientOptions",
"DeployEvent",
"GalaxyAttribute",
"GalaxyObject",
"GalaxyRepositoryClient",
"GatewayClient",
"MxAccessError",
"MxGatewayAuthenticationError",
@@ -31,6 +42,7 @@ __all__ = [
"MxGatewayWorkerError",
"MxValueView",
"Session",
"WatchDeployEventsRequest",
"__version__",
"auth_metadata",
"from_mx_value",
+199
View File
@@ -0,0 +1,199 @@
"""Async Galaxy Repository client wrapper.
Wraps the read-only ``GalaxyRepository`` gRPC service exposed by the
MxAccess Gateway. The service lets callers test connectivity to the AVEVA
System Platform Galaxy Repository (ZB SQL database), read the last
deployment timestamp, and enumerate the deployed object hierarchy plus the
attributes on each object.
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Sequence
from datetime import datetime
from typing import Any
import grpc
from google.protobuf.timestamp_pb2 import Timestamp
from .auth import merge_metadata
from .errors import map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from .options import ClientOptions, create_channel
class GalaxyRepositoryClient:
"""Async client for the Galaxy Repository gRPC service."""
def __init__(
self,
*,
options: ClientOptions,
stub: Any,
channel: grpc.aio.Channel | None = None,
) -> None:
self.options = options
self.raw_stub = stub
self._channel = channel
self._closed = False
@classmethod
async def connect(
cls,
options: ClientOptions | None = None,
*,
endpoint: str | None = None,
api_key: str | None = None,
plaintext: bool = False,
ca_file: str | None = None,
server_name_override: str | None = None,
stub: Any | None = None,
) -> "GalaxyRepositoryClient":
"""Create a client with either a real async channel or a supplied fake stub."""
resolved = options or ClientOptions(
endpoint=endpoint or "",
api_key=api_key,
plaintext=plaintext,
ca_file=ca_file,
server_name_override=server_name_override,
)
if stub is not None:
return cls(options=resolved, stub=stub)
channel = create_channel(resolved)
return cls(
options=resolved,
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
channel=channel,
)
async def __aenter__(self) -> "GalaxyRepositoryClient":
return self
async def __aexit__(self, *_exc_info: object) -> None:
await self.close()
async def close(self) -> None:
"""Close the owned gRPC channel."""
if self._closed:
return
if self._channel is not None:
await self._channel.close()
self._closed = True
async def test_connection(self) -> bool:
"""Return ``True`` when the gateway can reach the Galaxy Repository DB."""
reply = await self._unary(
"test connection",
self.raw_stub.TestConnection,
galaxy_pb.TestConnectionRequest(),
)
return bool(reply.ok)
async def get_last_deploy_time(self) -> datetime | None:
"""Return the last Galaxy deploy timestamp or ``None`` when unset."""
reply = await self._unary(
"get last deploy time",
self.raw_stub.GetLastDeployTime,
galaxy_pb.GetLastDeployTimeRequest(),
)
if not reply.present:
return None
return reply.time_of_last_deploy.ToDatetime()
async def discover_hierarchy(self) -> list[galaxy_pb.GalaxyObject]:
"""Return the deployed Galaxy object hierarchy as raw proto messages."""
reply = await self._unary(
"discover hierarchy",
self.raw_stub.DiscoverHierarchy,
galaxy_pb.DiscoverHierarchyRequest(),
)
return list(reply.objects)
def watch_deploy_events(
self,
last_seen_deploy_time: datetime | None = None,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> AsyncIterator[galaxy_pb.DeployEvent]:
"""Stream Galaxy deploy events.
On subscribe the gateway emits the current cached state and then one
event per new deploy time. ``sequence`` is monotonic per server start;
gaps mean events were dropped from the per-subscriber buffer. When
``last_seen_deploy_time`` is supplied and matches the current cached
deploy time the bootstrap event is suppressed.
"""
request = galaxy_pb.WatchDeployEventsRequest()
if last_seen_deploy_time is not None:
timestamp = Timestamp()
timestamp.FromDatetime(last_seen_deploy_time)
request.last_seen_deploy_time.CopyFrom(timestamp)
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.stream_timeout is not None:
kwargs["timeout"] = self.options.stream_timeout
try:
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = self.raw_stub.WatchDeployEvents(request, **kwargs)
return _canceling_iterator(call)
async def _unary(
self,
operation: str,
method: Any,
request: Any,
*,
metadata: Sequence[tuple[str, str]] | None = None,
) -> Any:
kwargs: dict[str, Any] = {"metadata": merge_metadata(self.options.api_key, metadata)}
if self.options.call_timeout is not None:
kwargs["timeout"] = self.options.call_timeout
try:
call = method(request, **kwargs)
except TypeError as error:
if "timeout" not in kwargs or "unexpected keyword argument 'timeout'" not in str(error):
raise
kwargs.pop("timeout")
call = method(request, **kwargs)
try:
return await call
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error(operation, error) from error
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
try:
async for event in call:
yield event
except asyncio.CancelledError:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
raise
except grpc.RpcError as error:
raise map_rpc_error("watch deploy events", error) from error
finally:
cancel = getattr(call, "cancel", None)
if cancel is not None:
cancel()
@@ -21,7 +21,15 @@ sys.modules.setdefault("mxaccess_worker_pb2", mxaccess_worker_pb2)
mxaccess_worker_pb2_grpc = import_module(f"{__name__}.mxaccess_worker_pb2_grpc")
sys.modules.setdefault("mxaccess_worker_pb2_grpc", mxaccess_worker_pb2_grpc)
galaxy_repository_pb2 = import_module(f"{__name__}.galaxy_repository_pb2")
sys.modules.setdefault("galaxy_repository_pb2", galaxy_repository_pb2)
galaxy_repository_pb2_grpc = import_module(f"{__name__}.galaxy_repository_pb2_grpc")
sys.modules.setdefault("galaxy_repository_pb2_grpc", galaxy_repository_pb2_grpc)
__all__ = [
"galaxy_repository_pb2",
"galaxy_repository_pb2_grpc",
"mxaccess_gateway_pb2",
"mxaccess_gateway_pb2_grpc",
"mxaccess_worker_pb2",
@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: galaxy_repository.proto
# Protobuf Python Version: 6.31.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
6,
31,
1,
'',
'galaxy_repository.proto'
)
# @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default()
from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x1a\n\x18\x44iscoverHierarchyRequest\"M\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42#\xaa\x02 MxGateway.Contracts.Proto.Galaxyb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'galaxy_repository_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
_globals['DESCRIPTOR']._loaded_options = None
_globals['DESCRIPTOR']._serialized_options = b'\252\002 MxGateway.Contracts.Proto.Galaxy'
_globals['_TESTCONNECTIONREQUEST']._serialized_start=82
_globals['_TESTCONNECTIONREQUEST']._serialized_end=105
_globals['_TESTCONNECTIONREPLY']._serialized_start=107
_globals['_TESTCONNECTIONREPLY']._serialized_end=140
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_start=142
_globals['_GETLASTDEPLOYTIMEREQUEST']._serialized_end=168
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_start=170
_globals['_GETLASTDEPLOYTIMEREPLY']._serialized_end=268
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_start=270
_globals['_DISCOVERHIERARCHYREQUEST']._serialized_end=296
_globals['_DISCOVERHIERARCHYREPLY']._serialized_start=298
_globals['_DISCOVERHIERARCHYREPLY']._serialized_end=375
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_start=377
_globals['_WATCHDEPLOYEVENTSREQUEST']._serialized_end=462
_globals['_DEPLOYEVENT']._serialized_start=465
_globals['_DEPLOYEVENT']._serialized_end=686
_globals['_GALAXYOBJECT']._serialized_start=689
_globals['_GALAXYOBJECT']._serialized_end=964
_globals['_GALAXYATTRIBUTE']._serialized_start=967
_globals['_GALAXYATTRIBUTE']._serialized_end=1263
_globals['_GALAXYREPOSITORY']._serialized_start=1266
_globals['_GALAXYREPOSITORY']._serialized_end=1726
# @@protoc_insertion_point(module_scope)
@@ -0,0 +1,244 @@
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
import warnings
import galaxy_repository_pb2 as galaxy__repository__pb2
GRPC_GENERATED_VERSION = '1.80.0'
GRPC_VERSION = grpc.__version__
_version_not_supported = False
try:
from grpc._utilities import first_version_is_lower
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
except ImportError:
_version_not_supported = True
if _version_not_supported:
raise RuntimeError(
f'The grpc package installed is at version {GRPC_VERSION},'
+ ' but the generated code in galaxy_repository_pb2_grpc.py depends on'
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
)
class GalaxyRepositoryStub(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.TestConnection = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
request_serializer=galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.TestConnectionReply.FromString,
_registered_method=True)
self.GetLastDeployTime = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
request_serializer=galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
_registered_method=True)
self.DiscoverHierarchy = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
request_serializer=galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
_registered_method=True)
self.WatchDeployEvents = channel.unary_stream(
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
def TestConnection(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def GetLastDeployTime(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def DiscoverHierarchy(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def WatchDeployEvents(self, request, context):
"""Server-stream of deploy events. The server emits the current state immediately
on subscribe (so clients can bootstrap their cache without waiting for the next
deploy), then emits one event each time the gateway's hierarchy cache observes
a new galaxy.time_of_last_deploy. The sequence field is monotonically
increasing per server start; gaps indicate the per-subscriber buffer dropped
older events because the client was too slow.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = {
'TestConnection': grpc.unary_unary_rpc_method_handler(
servicer.TestConnection,
request_deserializer=galaxy__repository__pb2.TestConnectionRequest.FromString,
response_serializer=galaxy__repository__pb2.TestConnectionReply.SerializeToString,
),
'GetLastDeployTime': grpc.unary_unary_rpc_method_handler(
servicer.GetLastDeployTime,
request_deserializer=galaxy__repository__pb2.GetLastDeployTimeRequest.FromString,
response_serializer=galaxy__repository__pb2.GetLastDeployTimeReply.SerializeToString,
),
'DiscoverHierarchy': grpc.unary_unary_rpc_method_handler(
servicer.DiscoverHierarchy,
request_deserializer=galaxy__repository__pb2.DiscoverHierarchyRequest.FromString,
response_serializer=galaxy__repository__pb2.DiscoverHierarchyReply.SerializeToString,
),
'WatchDeployEvents': grpc.unary_stream_rpc_method_handler(
servicer.WatchDeployEvents,
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class GalaxyRepository(object):
"""Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
database). Lets clients enumerate the deployed object hierarchy and each
object's dynamic attributes so they know what tag references to subscribe
to via the MxAccessGateway service.
"""
@staticmethod
def TestConnection(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/TestConnection',
galaxy__repository__pb2.TestConnectionRequest.SerializeToString,
galaxy__repository__pb2.TestConnectionReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def GetLastDeployTime(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime',
galaxy__repository__pb2.GetLastDeployTimeRequest.SerializeToString,
galaxy__repository__pb2.GetLastDeployTimeReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def DiscoverHierarchy(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy',
galaxy__repository__pb2.DiscoverHierarchyRequest.SerializeToString,
galaxy__repository__pb2.DiscoverHierarchyReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def WatchDeployEvents(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_stream(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents',
galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
galaxy__repository__pb2.DeployEvent.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+320
View File
@@ -0,0 +1,320 @@
"""Tests for the Galaxy Repository async client wrapper."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
import pytest
from google.protobuf.timestamp_pb2 import Timestamp
from mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
from mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
def test_galaxy_messages_import() -> None:
request = galaxy_pb.DiscoverHierarchyRequest()
obj = galaxy_pb.GalaxyObject(
gobject_id=42,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=10,
is_area=False,
category_id=4,
hosted_by_gobject_id=10,
template_chain=["$ApplicationObject", "$DelmiaReceiver"],
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
)
assert request.DESCRIPTOR is not None
assert obj.attributes[0].attribute_name == "DownloadPath"
assert hasattr(galaxy_pb_grpc, "GalaxyRepositoryStub")
@pytest.mark.asyncio
async def test_test_connection_returns_bool_and_sends_auth() -> None:
stub = FakeGalaxyStub()
stub.test_connection.replies = [galaxy_pb.TestConnectionReply(ok=True)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
result = await client.test_connection()
assert result is True
assert stub.test_connection.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.test_connection.requests[0], galaxy_pb.TestConnectionRequest)
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_datetime_when_present() -> None:
timestamp = Timestamp()
timestamp.FromDatetime(datetime(2025, 4, 1, 12, 30, 45, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [
galaxy_pb.GetLastDeployTimeReply(present=True, time_of_last_deploy=timestamp),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
when = await client.get_last_deploy_time()
assert when is not None
assert when.year == 2025
assert when.month == 4
assert when.day == 1
assert when.hour == 12
assert when.minute == 30
assert when.second == 45
@pytest.mark.asyncio
async def test_get_last_deploy_time_returns_none_when_not_present() -> None:
stub = FakeGalaxyStub()
stub.get_last_deploy_time.replies = [galaxy_pb.GetLastDeployTimeReply(present=False)]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
assert await client.get_last_deploy_time() is None
@pytest.mark.asyncio
async def test_discover_hierarchy_returns_proto_objects() -> None:
stub = FakeGalaxyStub()
stub.discover_hierarchy.replies = [
galaxy_pb.DiscoverHierarchyReply(
objects=[
galaxy_pb.GalaxyObject(
gobject_id=1,
tag_name="TestMachine_001",
contained_name="TestMachine",
browse_name="TestMachine_001",
is_area=True,
),
galaxy_pb.GalaxyObject(
gobject_id=2,
tag_name="DelmiaReceiver_001",
contained_name="DelmiaReceiver",
browse_name="DelmiaReceiver",
parent_gobject_id=1,
attributes=[
galaxy_pb.GalaxyAttribute(
attribute_name="DownloadPath",
full_tag_reference="DelmiaReceiver_001.DownloadPath",
mx_data_type=8,
data_type_name="String",
),
],
),
],
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
objects = await client.discover_hierarchy()
assert isinstance(objects, list)
assert len(objects) == 2
assert objects[0].tag_name == "TestMachine_001"
assert objects[1].attributes[0].full_tag_reference == "DelmiaReceiver_001.DownloadPath"
@pytest.mark.asyncio
async def test_watch_deploy_events_yields_events_in_order() -> None:
ts1 = Timestamp()
ts1.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
ts2 = Timestamp()
ts2.FromDatetime(datetime(2025, 4, 1, 11, 0, 0, tzinfo=timezone.utc))
events = [
galaxy_pb.DeployEvent(
sequence=1,
observed_at=ts1,
time_of_last_deploy=ts1,
time_of_last_deploy_present=True,
object_count=10,
attribute_count=42,
),
galaxy_pb.DeployEvent(
sequence=2,
observed_at=ts2,
time_of_last_deploy=ts2,
time_of_last_deploy_present=True,
object_count=11,
attribute_count=45,
),
]
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = list(events)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
stub=stub,
)
received: list[DeployEvent] = []
async for event in client.watch_deploy_events():
received.append(event)
assert len(received) == 2
assert received[0].sequence == 1
assert received[1].sequence == 2
assert received[0].object_count == 10
assert received[1].attribute_count == 45
assert stub.watch_deploy_events.metadata == (("authorization", "Bearer mxgw_test_secret"),)
assert isinstance(stub.watch_deploy_events.requests[0], galaxy_pb.WatchDeployEventsRequest)
# No last_seen_deploy_time was passed, so the request should leave it unset.
assert not stub.watch_deploy_events.requests[0].HasField("last_seen_deploy_time")
@pytest.mark.asyncio
async def test_watch_deploy_events_propagates_last_seen_deploy_time() -> None:
last_seen = datetime(2025, 4, 1, 12, 0, 0, tzinfo=timezone.utc)
stub = FakeGalaxyStub()
stub.watch_deploy_events.replies = []
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
async for _ in client.watch_deploy_events(last_seen_deploy_time=last_seen):
pass
request = stub.watch_deploy_events.requests[0]
assert isinstance(request, WatchDeployEventsRequest)
assert request.HasField("last_seen_deploy_time")
assert request.last_seen_deploy_time.ToDatetime(tzinfo=timezone.utc) == last_seen
@pytest.mark.asyncio
async def test_watch_deploy_events_cancellation_closes_stream() -> None:
ts = Timestamp()
ts.FromDatetime(datetime(2025, 4, 1, 10, 0, 0, tzinfo=timezone.utc))
stub = FakeGalaxyStub()
# Use a "blocking" stream that never yields more after the first event.
stub.watch_deploy_events = FakeStream(
[galaxy_pb.DeployEvent(sequence=1, observed_at=ts)],
block_after_replies=True,
)
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
iterator = client.watch_deploy_events()
first = await iterator.__anext__()
assert first.sequence == 1
# Break the iterator by aclose() — this should drive the cancel path.
await iterator.aclose()
assert stub.watch_deploy_events.cancel_called is True
@pytest.mark.asyncio
async def test_close_marks_channel_closed_when_no_real_channel() -> None:
stub = FakeGalaxyStub()
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
await client.close()
# Idempotent: a second close should not raise.
await client.close()
class FakeGalaxyStub:
def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
self.watch_deploy_events = FakeStream([])
self.TestConnection = self.test_connection
self.GetLastDeployTime = self.get_last_deploy_time
self.DiscoverHierarchy = self.discover_hierarchy
@property
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
return self.watch_deploy_events
class FakeUnary:
def __init__(self, replies: list[Any]) -> None:
self.replies = replies
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
timeout: float | None = None,
) -> Any:
self.requests.append(request)
self.metadata = metadata
return self.replies.pop(0)
class FakeStream:
"""Sync-callable fake matching the gRPC unary-stream surface.
Calling the stub returns ``self`` (an async iterator). After exhausting the
seeded ``replies``, iteration either ends (default) or blocks indefinitely
(``block_after_replies=True``) so cancellation paths can be exercised.
"""
def __init__(
self,
replies: list[Any],
*,
block_after_replies: bool = False,
) -> None:
self.replies = list(replies)
self.requests: list[Any] = []
self.metadata: tuple[tuple[str, str], ...] | None = None
self.cancel_called = False
self._block_after_replies = block_after_replies
def __call__(
self,
request: Any,
*,
metadata: tuple[tuple[str, str], ...],
timeout: float | None = None,
) -> "FakeStream":
self.requests.append(request)
self.metadata = metadata
return self
def __aiter__(self) -> "FakeStream":
return self
async def __anext__(self) -> Any:
if self.replies:
return self.replies.pop(0)
if self._block_after_replies:
# Sleep forever until the consumer cancels us.
await asyncio.Event().wait()
raise StopAsyncIteration
def cancel(self) -> None:
self.cancel_called = True
+12
View File
@@ -595,6 +595,7 @@ dependencies = [
"clap",
"futures-util",
"mxgateway-client",
"prost-types",
"serde",
"serde_json",
"tokio",
@@ -886,6 +887,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
@@ -990,6 +1001,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
+70
View File
@@ -99,6 +99,76 @@ preserving the raw message for parity diagnostics. Command replies whose
protocol status is not `PROTOCOL_STATUS_CODE_OK` become `Error::Command` and
retain the raw `MxCommandReply`.
## Galaxy Repository browse
The Galaxy Repository service exposes a read-only browse over the AVEVA System
Platform Galaxy Repository (ZB SQL database). It uses the same API-key auth as
the gateway service but requires the `metadata:read` scope on the server.
[`GalaxyClient`](src/galaxy.rs) wraps the generated Galaxy bindings the same
way [`GatewayClient`](src/client.rs) wraps the gateway bindings:
```rust
let mut galaxy = GalaxyClient::connect(
ClientOptions::new("http://localhost:5000")
.with_api_key(ApiKey::new(api_key)),
).await?;
let ok = galaxy.test_connection().await?;
let last_deploy = galaxy.get_last_deploy_time().await?; // Option<prost_types::Timestamp>
let objects = galaxy.discover_hierarchy().await?; // Vec<GalaxyObject>
```
`get_last_deploy_time` returns `None` when the server reports
`present = false`. `discover_hierarchy` returns the generated
`GalaxyObject` proto type (re-exported via
`mxgateway_client::generated::galaxy_repository::v1`) with all attributes
attached.
The CLI ships matching subcommands under `galaxy`:
```powershell
cargo run -p mxgw-cli -- galaxy test-connection --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
```
### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
server emits a bootstrap [`DeployEvent`](src/galaxy.rs) describing the
current cache state on subscribe, then one event each time the cached
`galaxy.time_of_last_deploy` changes. `sequence` is monotonic per server
start; gaps signal that the per-subscriber buffer dropped older events.
Pass `last_seen_deploy_time` to suppress the bootstrap event when the
client's cached deploy time matches the server's.
```rust
use futures_util::StreamExt;
let mut stream = galaxy.watch_deploy_events(None).await?;
while let Some(event) = stream.next().await {
let event = event?;
println!(
"seq={} objects={} attributes={}",
event.sequence, event.object_count, event.attribute_count,
);
}
// Drop the stream to cancel the gRPC call.
```
The matching CLI subcommand prints one line per event (`--json` switches to
one JSON object per event). `--last-seen-deploy-time` accepts an RFC3339
timestamp and is forwarded to the server. `--max-events` (default 0 = no
cap) lets you stop after a fixed number of events; otherwise the command
runs until the stream ends or `Ctrl+C` is pressed.
```powershell
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
cargo run -p mxgw-cli -- galaxy watch --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --last-seen-deploy-time 2026-04-28T15:30:00Z
```
## Integration Checks
Run live checks only when a gateway and MXAccess-backed worker are available:
+7 -1
View File
@@ -13,17 +13,23 @@ fn main() -> Result<(), Box<dyn Error>> {
let proto_root = repo_root.join("src/MxGateway.Contracts/Protos");
let gateway_proto = proto_root.join("mxaccess_gateway.proto");
let worker_proto = proto_root.join("mxaccess_worker.proto");
let galaxy_proto = proto_root.join("galaxy_repository.proto");
let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("mxaccessgw-client-v1.protoset");
println!("cargo:rerun-if-changed={}", gateway_proto.display());
println!("cargo:rerun-if-changed={}", worker_proto.display());
println!("cargo:rerun-if-changed={}", galaxy_proto.display());
tonic_build::configure()
.build_server(true)
.build_client(true)
.file_descriptor_set_path(descriptor_path)
.compile_protos(
&[gateway_proto.as_path(), worker_proto.as_path()],
&[
gateway_proto.as_path(),
worker_proto.as_path(),
galaxy_proto.as_path(),
],
&[proto_root.as_path()],
)?;
+2 -1
View File
@@ -12,6 +12,7 @@ path = "src/main.rs"
clap = { workspace = true }
futures-util = { workspace = true }
mxgateway-client = { path = "../.." }
prost-types = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "sync", "time"] }
+412 -2
View File
@@ -5,13 +5,14 @@ use std::time::Duration;
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures_util::StreamExt;
use mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use mxgateway_client::generated::mxaccess_gateway::v1::{
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, OpenSessionRequest,
PingCommand, StreamEventsRequest,
};
use mxgateway_client::{
ApiKey, ClientOptions, Error, GatewayClient, MxValue, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION,
WORKER_PROTOCOL_VERSION,
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, CLIENT_VERSION,
GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
use serde_json::json;
use serde_json::Value;
@@ -178,6 +179,51 @@ enum Command {
#[arg(long)]
json: bool,
},
#[command(subcommand)]
Galaxy(GalaxyCommand),
}
#[derive(Debug, Subcommand)]
enum GalaxyCommand {
TestConnection {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
LastDeployTime {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
DiscoverHierarchy {
#[command(flatten)]
connection: ConnectionArgs,
#[arg(long)]
json: bool,
},
/// Subscribe to the WatchDeployEvents server stream.
///
/// Prints one line per received event (or one JSON object with `--json`).
/// Runs until the stream ends, the server fails the call, or the
/// process is interrupted (Ctrl+C).
#[command(alias = "watch-deploy-events")]
Watch {
#[command(flatten)]
connection: ConnectionArgs,
/// Optional RFC3339 timestamp (e.g. `2026-04-28T15:30:00Z`). When
/// supplied, the server suppresses the bootstrap event if its
/// cached deploy time matches this value.
#[arg(long)]
last_seen_deploy_time: Option<String>,
/// Optional cap on the number of events to print before exiting.
/// 0 (the default) means run until cancelled or the stream ends.
#[arg(long, default_value_t = 0)]
max_events: usize,
#[arg(long)]
json: bool,
},
}
#[derive(Debug, Args, Clone)]
@@ -465,6 +511,7 @@ async fn run(cli: Cli) -> Result<(), Error> {
.await?;
print_ok("write2", json);
}
Command::Galaxy(galaxy_command) => run_galaxy(galaxy_command).await?,
Command::Smoke {
connection,
item,
@@ -514,6 +561,133 @@ async fn connect(connection: ConnectionArgs) -> Result<GatewayClient, Error> {
GatewayClient::connect(connection.options()).await
}
async fn connect_galaxy(connection: ConnectionArgs) -> Result<GalaxyClient, Error> {
GalaxyClient::connect(connection.options()).await
}
async fn run_galaxy(command: GalaxyCommand) -> Result<(), Error> {
match command {
GalaxyCommand::TestConnection { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let ok = client.test_connection().await?;
if json {
println!("{}", json!({ "ok": ok }));
} else if ok {
println!("ok");
} else {
println!("not ok");
}
}
GalaxyCommand::LastDeployTime { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let timestamp = client.get_last_deploy_time().await?;
match (json, timestamp) {
(true, Some(ts)) => {
println!(
"{}",
json!({
"present": true,
"seconds": ts.seconds,
"nanos": ts.nanos,
})
);
}
(true, None) => {
println!("{}", json!({ "present": false }));
}
(false, Some(ts)) => println!("{}.{:09}", ts.seconds, ts.nanos),
(false, None) => println!("(absent)"),
}
}
GalaxyCommand::Watch {
connection,
last_seen_deploy_time,
max_events,
json,
} => {
let mut client = connect_galaxy(connection).await?;
let last_seen = last_seen_deploy_time
.as_deref()
.map(parse_rfc3339_timestamp)
.transpose()?;
let mut stream = client.watch_deploy_events(last_seen).await?;
let mut count = 0usize;
loop {
tokio::select! {
biased;
signal = tokio::signal::ctrl_c() => {
signal.map_err(|err| Error::InvalidArgument {
name: "ctrl_c".to_owned(),
detail: err.to_string(),
})?;
// Drop the stream below by breaking; tonic tears the
// gRPC call down cooperatively.
break;
}
next = stream.next() => {
let Some(event) = next else { break; };
let event = event?;
count += 1;
print_deploy_event(&event, json);
if max_events != 0 && count >= max_events {
break;
}
}
}
}
}
GalaxyCommand::DiscoverHierarchy { connection, json } => {
let mut client = connect_galaxy(connection).await?;
let objects = client.discover_hierarchy().await?;
if json {
let payload: Vec<_> = objects
.iter()
.map(|object| {
json!({
"gobjectId": object.gobject_id,
"tagName": object.tag_name,
"containedName": object.contained_name,
"browseName": object.browse_name,
"parentGobjectId": object.parent_gobject_id,
"isArea": object.is_area,
"categoryId": object.category_id,
"hostedByGobjectId": object.hosted_by_gobject_id,
"templateChain": object.template_chain,
"attributes": object.attributes.iter().map(|attribute| json!({
"attributeName": attribute.attribute_name,
"fullTagReference": attribute.full_tag_reference,
"mxDataType": attribute.mx_data_type,
"dataTypeName": attribute.data_type_name,
"isArray": attribute.is_array,
"arrayDimension": attribute.array_dimension,
"arrayDimensionPresent": attribute.array_dimension_present,
"mxAttributeCategory": attribute.mx_attribute_category,
"securityClassification": attribute.security_classification,
"isHistorized": attribute.is_historized,
"isAlarm": attribute.is_alarm,
})).collect::<Vec<_>>(),
})
})
.collect();
println!("{}", json!({ "objects": payload }));
} else {
println!("{}", objects.len());
for object in &objects {
println!(
"{} {} {} ({} attribute(s))",
object.gobject_id,
object.tag_name,
object.browse_name,
object.attributes.len()
);
}
}
}
}
Ok(())
}
async fn session_for(
connection: ConnectionArgs,
session_id: String,
@@ -616,6 +790,208 @@ fn parse_value(value_type: CliValueType, value: &str) -> Result<MxValue, Error>
Ok(parsed)
}
fn print_deploy_event(event: &DeployEvent, use_json: bool) {
if use_json {
println!(
"{}",
json!({
"sequence": event.sequence,
"observedAt": event.observed_at.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeploy": event.time_of_last_deploy.as_ref().map(|ts| json!({
"seconds": ts.seconds,
"nanos": ts.nanos,
})),
"timeOfLastDeployPresent": event.time_of_last_deploy_present,
"objectCount": event.object_count,
"attributeCount": event.attribute_count,
})
);
} else {
let observed = event
.observed_at
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned());
let last_deploy = if event.time_of_last_deploy_present {
event
.time_of_last_deploy
.as_ref()
.map(|ts| format!("{}.{:09}", ts.seconds, ts.nanos))
.unwrap_or_else(|| "(absent)".to_owned())
} else {
"(absent)".to_owned()
};
println!(
"seq={} observed={} lastDeploy={} objects={} attributes={}",
event.sequence, observed, last_deploy, event.object_count, event.attribute_count,
);
}
}
/// Parse a small but practically-complete subset of RFC3339:
/// `YYYY-MM-DDTHH:MM:SS[.fffffffff][Z|+HH:MM|-HH:MM]`. Returns the
/// corresponding `prost_types::Timestamp` (Unix seconds + nanoseconds).
fn parse_rfc3339_timestamp(input: &str) -> Result<prost_types::Timestamp, Error> {
fn invalid(detail: impl Into<String>) -> Error {
Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: detail.into(),
}
}
let bytes = input.as_bytes();
if bytes.len() < 20 || (bytes[10] != b'T' && bytes[10] != b't') {
return Err(invalid(format!(
"expected RFC3339 timestamp like 2026-04-28T15:30:00Z, got {input:?}"
)));
}
let read_u32 = |start: usize, len: usize| -> Result<u32, Error> {
std::str::from_utf8(&bytes[start..start + len])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid(format!("non-numeric digits at byte {start}")))
};
let year = read_u32(0, 4)? as i32;
if bytes[4] != b'-' {
return Err(invalid("expected '-' after year"));
}
let month = read_u32(5, 2)?;
if bytes[7] != b'-' {
return Err(invalid("expected '-' after month"));
}
let day = read_u32(8, 2)?;
let hour = read_u32(11, 2)?;
if bytes[13] != b':' {
return Err(invalid("expected ':' after hour"));
}
let minute = read_u32(14, 2)?;
if bytes[16] != b':' {
return Err(invalid("expected ':' after minute"));
}
let second = read_u32(17, 2)?;
let mut cursor = 19usize;
let mut nanos: u32 = 0;
if cursor < bytes.len() && bytes[cursor] == b'.' {
cursor += 1;
let frac_start = cursor;
while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
cursor += 1;
}
let frac_len = cursor - frac_start;
if frac_len == 0 {
return Err(invalid("expected fractional digits after '.'"));
}
let take = frac_len.min(9);
let frac = std::str::from_utf8(&bytes[frac_start..frac_start + take])
.ok()
.and_then(|slice| slice.parse::<u32>().ok())
.ok_or_else(|| invalid("invalid fractional digits"))?;
nanos = frac * 10u32.pow(9u32.saturating_sub(take as u32));
}
let mut offset_seconds: i64 = 0;
if cursor >= bytes.len() {
return Err(invalid("missing timezone designator (Z or +HH:MM)"));
}
match bytes[cursor] {
b'Z' | b'z' => cursor += 1,
sign @ (b'+' | b'-') => {
cursor += 1;
if cursor + 5 > bytes.len() {
return Err(invalid("offset must be ±HH:MM"));
}
let oh = std::str::from_utf8(&bytes[cursor..cursor + 2])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset hour"))?;
if bytes[cursor + 2] != b':' {
return Err(invalid("offset must contain ':' between HH and MM"));
}
let om = std::str::from_utf8(&bytes[cursor + 3..cursor + 5])
.ok()
.and_then(|slice| slice.parse::<i64>().ok())
.ok_or_else(|| invalid("invalid offset minute"))?;
cursor += 5;
let signed = if sign == b'-' { -1 } else { 1 };
offset_seconds = signed * (oh * 3600 + om * 60);
}
other => {
return Err(invalid(format!(
"unexpected timezone designator byte {other:?}"
)));
}
}
if cursor != bytes.len() {
return Err(invalid("trailing characters after timezone"));
}
let unix = ymdhms_to_unix(year, month, day, hour, minute, second)?;
let seconds = unix - offset_seconds;
Ok(prost_types::Timestamp {
seconds,
nanos: nanos as i32,
})
}
fn ymdhms_to_unix(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
) -> Result<i64, Error> {
if !(1..=12).contains(&month) || day < 1 || hour > 23 || minute > 59 || second > 60 {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: "calendar component out of range".to_owned(),
});
}
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
const DAYS: [u32; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut max = DAYS[(month - 1) as usize];
if month == 2 && is_leap(year) {
max = 29;
}
if day > max {
return Err(Error::InvalidArgument {
name: "last-seen-deploy-time".to_owned(),
detail: format!("day {day} out of range for month {month}/{year}"),
});
}
// Days from 1970-01-01 to year-month-day.
let mut total_days: i64 = 0;
if year >= 1970 {
for y in 1970..year {
total_days += if is_leap(y) { 366 } else { 365 };
}
} else {
for y in year..1970 {
total_days -= if is_leap(y) { 366 } else { 365 };
}
}
for m in 1..month {
let mut dim = DAYS[(m - 1) as usize];
if m == 2 && is_leap(year) {
dim = 29;
}
total_days += dim as i64;
}
total_days += (day - 1) as i64;
Ok(total_days * 86_400 + hour as i64 * 3600 + minute as i64 * 60 + second as i64)
}
fn parse_cli_value<T>(value: &str) -> Result<T, Error>
where
T: std::str::FromStr,
@@ -665,4 +1041,38 @@ mod tests {
assert_eq!(value["gatewayProtocolVersion"], 1);
assert_eq!(value["workerProtocolVersion"], 1);
}
#[test]
fn parses_galaxy_watch_command_with_last_seen_and_max_events() {
let parsed = Cli::try_parse_from([
"mxgw",
"galaxy",
"watch",
"--last-seen-deploy-time",
"2026-04-28T15:30:00Z",
"--max-events",
"5",
"--json",
]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn parses_galaxy_watch_deploy_events_alias() {
let parsed = Cli::try_parse_from(["mxgw", "galaxy", "watch-deploy-events"]);
assert!(parsed.is_ok(), "parse failed: {parsed:?}");
}
#[test]
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
let utc = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Z").unwrap();
let plus = super::parse_rfc3339_timestamp("2026-04-28T16:30:00+01:00").unwrap();
let frac = super::parse_rfc3339_timestamp("2026-04-28T15:30:00.250Z").unwrap();
assert_eq!(utc.seconds, plus.seconds);
assert_eq!(utc.nanos, 0);
assert_eq!(frac.seconds, utc.seconds);
assert_eq!(frac.nanos, 250_000_000);
}
}
+591
View File
@@ -0,0 +1,591 @@
//! Thin async wrapper for the `GalaxyRepository` gRPC service.
//!
//! The wrapper mirrors [`crate::client::GatewayClient`]: it owns a tonic
//! channel with the shared bearer-token interceptor and exposes the three
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
//! re-exported through [`crate::generated::galaxy_repository::v1`].
use std::fs;
use prost_types::Timestamp;
use tonic::codegen::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
use tonic::Request;
use crate::auth::AuthInterceptor;
use crate::error::Error;
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
TestConnectionRequest, WatchDeployEventsRequest,
};
use crate::options::ClientOptions;
/// Convenience alias for the generated Galaxy client wrapped in the
/// authentication interceptor.
pub type RawGalaxyClient = GalaxyRepositoryClient<InterceptedService<Channel, AuthInterceptor>>;
/// Stream of `DeployEvent` values returned by
/// [`GalaxyClient::watch_deploy_events`]. Mirrors
/// [`crate::client::EventStream`]: a boxed `Stream` whose `tonic::Status`
/// errors have already been mapped onto [`Error`]. Dropping the stream
/// cancels the underlying gRPC call.
pub type DeployEventStream = std::pin::Pin<
Box<dyn futures_core::Stream<Item = Result<DeployEvent, Error>> + Send + 'static>,
>;
/// Thin async wrapper around the generated Galaxy Repository gRPC client.
///
/// Construct it with [`GalaxyClient::connect`] using the same
/// [`ClientOptions`] that drive [`crate::client::GatewayClient`]. The
/// service is metadata-only (no sessions) and requires the `metadata:read`
/// API-key scope on the server side.
#[derive(Clone)]
pub struct GalaxyClient {
inner: RawGalaxyClient,
call_timeout: std::time::Duration,
stream_timeout: Option<std::time::Duration>,
}
impl GalaxyClient {
/// Connect to the gateway endpoint and build a Galaxy client. Mirrors
/// the TLS / plaintext / API-key handling used by `GatewayClient`.
pub async fn connect(options: ClientOptions) -> Result<Self, Error> {
let mut endpoint =
Channel::from_shared(options.endpoint().to_owned()).map_err(|source| {
Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: source.to_string(),
}
})?;
endpoint = endpoint.connect_timeout(options.connect_timeout());
if !options.plaintext() {
let mut tls = ClientTlsConfig::new();
if let Some(server_name) = options.server_name_override() {
tls = tls.domain_name(server_name.to_owned());
}
if let Some(ca_file) = options.ca_file() {
let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint {
endpoint: options.endpoint().to_owned(),
detail: format!("failed to read CA file {}: {source}", ca_file.display()),
})?;
tls = tls.ca_certificate(Certificate::from_pem(certificate));
}
endpoint = endpoint.tls_config(tls)?;
}
let channel = endpoint.connect().await?;
let interceptor = AuthInterceptor::new(options.api_key().cloned());
Ok(Self {
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
call_timeout: options.call_timeout(),
stream_timeout: options.stream_timeout(),
})
}
/// Build a [`GalaxyClient`] that talks through an existing tonic
/// channel. Tests use this to wire up an in-memory transport.
pub fn from_channel(channel: Channel, options: &ClientOptions) -> Self {
let interceptor = AuthInterceptor::new(options.api_key().cloned());
Self {
inner: GalaxyRepositoryClient::with_interceptor(channel, interceptor),
call_timeout: options.call_timeout(),
stream_timeout: options.stream_timeout(),
}
}
/// Borrow the underlying generated client for advanced callers that need
/// access to features not surfaced by the wrapper.
pub fn raw_client(&mut self) -> &mut RawGalaxyClient {
&mut self.inner
}
/// Consume the wrapper and return the generated client.
pub fn into_inner(self) -> RawGalaxyClient {
self.inner
}
/// Probe the Galaxy Repository database connection. Returns the `ok`
/// flag from the server reply.
pub async fn test_connection(&mut self) -> Result<bool, Error> {
let response = self
.inner
.test_connection(self.unary_request(TestConnectionRequest {}))
.await?;
Ok(response.into_inner().ok)
}
/// Read the most recent Galaxy deployment timestamp. Returns `None`
/// when the server reports `present = false`.
pub async fn get_last_deploy_time(&mut self) -> Result<Option<Timestamp>, Error> {
let response = self
.inner
.get_last_deploy_time(self.unary_request(GetLastDeployTimeRequest {}))
.await?;
let reply = response.into_inner();
if reply.present {
Ok(reply.time_of_last_deploy)
} else {
Ok(None)
}
}
/// Walk the deployed object hierarchy. Each [`GalaxyObject`] contains
/// the object's identifying names plus its dynamic attributes.
pub async fn discover_hierarchy(&mut self) -> Result<Vec<GalaxyObject>, Error> {
let response = self
.inner
.discover_hierarchy(self.unary_request(DiscoverHierarchyRequest {}))
.await?;
Ok(response.into_inner().objects)
}
/// Subscribe to the server-streamed deploy-event feed.
///
/// The server emits a bootstrap event describing the current cache state
/// immediately on subscribe, then one event per observed change to
/// `galaxy.time_of_last_deploy`. When `last_seen_deploy_time` matches the
/// current cache, the bootstrap event is suppressed and the stream stays
/// idle until the next deploy.
///
/// Cancellation is cooperative: dropping the returned
/// [`DeployEventStream`] tears down the underlying gRPC call. Callers
/// drive consumption with `StreamExt::next` (or any other `Stream`
/// adapter).
pub async fn watch_deploy_events(
&mut self,
last_seen_deploy_time: Option<Timestamp>,
) -> Result<DeployEventStream, Error> {
let request = WatchDeployEventsRequest {
last_seen_deploy_time,
};
let response = self
.inner
.watch_deploy_events(self.stream_request(request))
.await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from)
});
Ok(Box::pin(stream))
}
fn unary_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
request.set_timeout(self.call_timeout);
request
}
fn stream_request<T>(&self, message: T) -> Request<T> {
let mut request = Request::new(message);
if let Some(timeout) = self.stream_timeout {
request.set_timeout(timeout);
}
request
}
}
#[cfg(test)]
mod tests {
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use futures_util::StreamExt;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream};
use tonic::transport::Server;
use tonic::{Request, Response, Status};
use super::*;
use crate::auth::ApiKey;
use crate::generated::galaxy_repository::v1::galaxy_repository_server::{
GalaxyRepository, GalaxyRepositoryServer,
};
use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
TestConnectionRequest, WatchDeployEventsRequest,
};
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
#[derive(Default)]
struct FakeState {
authorization: Mutex<Option<String>>,
present: Mutex<bool>,
last_deploy: Mutex<Option<Timestamp>>,
objects: Mutex<Vec<GalaxyObject>>,
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
watch_events: Mutex<Vec<DeployEvent>>,
watch_senders: Mutex<Vec<DeployEventTx>>,
watch_drop_signal: Mutex<Option<mpsc::UnboundedSender<()>>>,
}
#[derive(Clone)]
struct FakeGalaxy {
state: Arc<FakeState>,
}
#[tonic::async_trait]
impl GalaxyRepository for FakeGalaxy {
async fn test_connection(
&self,
request: Request<TestConnectionRequest>,
) -> Result<Response<TestConnectionReply>, Status> {
*self.state.authorization.lock().unwrap() = request
.metadata()
.get("authorization")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
Ok(Response::new(TestConnectionReply { ok: true }))
}
async fn get_last_deploy_time(
&self,
_request: Request<GetLastDeployTimeRequest>,
) -> Result<Response<GetLastDeployTimeReply>, Status> {
let present = *self.state.present.lock().unwrap();
let time = self.state.last_deploy.lock().unwrap().clone();
Ok(Response::new(GetLastDeployTimeReply {
present,
time_of_last_deploy: time,
}))
}
async fn discover_hierarchy(
&self,
_request: Request<DiscoverHierarchyRequest>,
) -> Result<Response<DiscoverHierarchyReply>, Status> {
Ok(Response::new(DiscoverHierarchyReply {
objects: self.state.objects.lock().unwrap().clone(),
}))
}
type WatchDeployEventsStream =
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
async fn watch_deploy_events(
&self,
request: Request<WatchDeployEventsRequest>,
) -> Result<Response<Self::WatchDeployEventsStream>, Status> {
self.state
.watch_requests
.lock()
.unwrap()
.push(request.into_inner());
let preset = self.state.watch_events.lock().unwrap().clone();
let (tx, rx) = mpsc::channel::<Result<DeployEvent, Status>>(16);
for event in preset {
tx.send(Ok(event))
.await
.map_err(|err| Status::internal(err.to_string()))?;
}
self.state.watch_senders.lock().unwrap().push(tx.clone());
let drop_signal = self.state.watch_drop_signal.lock().unwrap().clone();
let stream = ReceiverStream::new(rx);
let stream: Pin<Box<dyn tokio_stream::Stream<Item = _> + Send + 'static>> =
if let Some(signal) = drop_signal {
Box::pin(WatchStreamWithDropSignal {
inner: stream,
signal: Some(signal),
})
} else {
Box::pin(stream)
};
Ok(Response::new(stream))
}
}
/// Wraps the receiver stream so we can detect when the server-side stream
/// future is dropped (the client cancelled or dropped the stream). Used by
/// `watch_drop_tears_down_call`.
struct WatchStreamWithDropSignal<S> {
inner: S,
signal: Option<mpsc::UnboundedSender<()>>,
}
impl<S: tokio_stream::Stream + Unpin> tokio_stream::Stream for WatchStreamWithDropSignal<S> {
type Item = S::Item;
fn poll_next(
mut self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
Pin::new(&mut self.inner).poll_next(cx)
}
}
impl<S> Drop for WatchStreamWithDropSignal<S> {
fn drop(&mut self) {
if let Some(signal) = self.signal.take() {
let _ = signal.send(());
}
}
}
async fn spawn_fake(state: Arc<FakeState>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let incoming = TcpListenerStream::new(listener);
let service = GalaxyRepositoryServer::new(FakeGalaxy { state });
tokio::spawn(async move {
Server::builder()
.add_service(service)
.serve_with_incoming(incoming)
.await
.unwrap();
});
format!("http://{address}")
}
#[tokio::test]
async fn test_connection_attaches_bearer_metadata_and_returns_ok() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(
ClientOptions::new(endpoint).with_api_key(ApiKey::new("mxgw_galaxy_secret")),
)
.await
.unwrap();
let ok = client.test_connection().await.unwrap();
assert!(ok);
assert_eq!(
state.authorization.lock().unwrap().as_deref(),
Some("Bearer mxgw_galaxy_secret")
);
}
#[tokio::test]
async fn get_last_deploy_time_returns_none_when_not_present() {
let state = Arc::new(FakeState::default());
*state.present.lock().unwrap() = false;
*state.last_deploy.lock().unwrap() = Some(Timestamp {
seconds: 1_700_000_000,
nanos: 0,
});
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let result = client.get_last_deploy_time().await.unwrap();
assert!(
result.is_none(),
"present=false on the wire must surface as None, got {result:?}"
);
}
#[tokio::test]
async fn get_last_deploy_time_returns_timestamp_when_present() {
let state = Arc::new(FakeState::default());
*state.present.lock().unwrap() = true;
*state.last_deploy.lock().unwrap() = Some(Timestamp {
seconds: 1_700_000_000,
nanos: 250_000_000,
});
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let result = client.get_last_deploy_time().await.unwrap();
let timestamp = result.expect("present=true should yield a timestamp");
assert_eq!(timestamp.seconds, 1_700_000_000);
assert_eq!(timestamp.nanos, 250_000_000);
}
#[tokio::test]
async fn discover_hierarchy_returns_objects_with_attributes() {
let state = Arc::new(FakeState::default());
*state.objects.lock().unwrap() = vec![GalaxyObject {
gobject_id: 42,
tag_name: "DelmiaReceiver_001".to_owned(),
contained_name: "DelmiaReceiver".to_owned(),
browse_name: "TestMachine_001/DelmiaReceiver".to_owned(),
parent_gobject_id: 7,
is_area: false,
category_id: 3,
hosted_by_gobject_id: 1,
template_chain: vec!["$UserDefined".to_owned(), "$DelmiaReceiver".to_owned()],
attributes: vec![GalaxyAttribute {
attribute_name: "DownloadPath".to_owned(),
full_tag_reference: "DelmiaReceiver_001.DownloadPath".to_owned(),
mx_data_type: 8,
data_type_name: "MxString".to_owned(),
is_array: false,
array_dimension: 0,
array_dimension_present: false,
mx_attribute_category: 2,
security_classification: 1,
is_historized: false,
is_alarm: false,
}],
}];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let objects = client.discover_hierarchy().await.unwrap();
assert_eq!(objects.len(), 1);
assert_eq!(objects[0].tag_name, "DelmiaReceiver_001");
assert_eq!(objects[0].attributes.len(), 1);
assert_eq!(objects[0].attributes[0].attribute_name, "DownloadPath");
assert_eq!(
objects[0].attributes[0].full_tag_reference,
"DelmiaReceiver_001.DownloadPath"
);
}
#[tokio::test]
async fn watch_deploy_events_yields_events_in_order() {
let state = Arc::new(FakeState::default());
*state.watch_events.lock().unwrap() = vec![
DeployEvent {
sequence: 1,
observed_at: Some(Timestamp {
seconds: 1_700_000_000,
nanos: 0,
}),
time_of_last_deploy: Some(Timestamp {
seconds: 1_699_000_000,
nanos: 0,
}),
time_of_last_deploy_present: true,
object_count: 12,
attribute_count: 80,
},
DeployEvent {
sequence: 2,
observed_at: Some(Timestamp {
seconds: 1_700_000_500,
nanos: 0,
}),
time_of_last_deploy: Some(Timestamp {
seconds: 1_699_500_000,
nanos: 0,
}),
time_of_last_deploy_present: true,
object_count: 13,
attribute_count: 85,
},
];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client.watch_deploy_events(None).await.unwrap();
let first = stream
.next()
.await
.expect("bootstrap event")
.expect("ok deploy event");
let second = stream
.next()
.await
.expect("second event")
.expect("ok deploy event");
assert_eq!(first.sequence, 1);
assert_eq!(first.object_count, 12);
assert_eq!(second.sequence, 2);
assert_eq!(second.object_count, 13);
assert!(first.time_of_last_deploy_present);
}
#[tokio::test]
async fn watch_deploy_events_propagates_last_seen_deploy_time() {
let state = Arc::new(FakeState::default());
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let last_seen = Timestamp {
seconds: 1_699_999_999,
nanos: 123_456_789,
};
let stream = client.watch_deploy_events(Some(last_seen)).await.unwrap();
// Drop the stream right away — the test is solely about the request
// payload reaching the server.
drop(stream);
// Give the server task a moment to record the request.
for _ in 0..20 {
if !state.watch_requests.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
let requests = state.watch_requests.lock().unwrap().clone();
assert_eq!(requests.len(), 1);
let recorded = requests[0]
.last_seen_deploy_time
.as_ref()
.expect("last_seen_deploy_time forwarded");
assert_eq!(recorded.seconds, last_seen.seconds);
assert_eq!(recorded.nanos, last_seen.nanos);
}
#[tokio::test]
async fn watch_deploy_events_drop_tears_down_call() {
let state = Arc::new(FakeState::default());
let (signal_tx, mut signal_rx) = mpsc::unbounded_channel();
*state.watch_drop_signal.lock().unwrap() = Some(signal_tx);
// Seed one event so the client gets something on the stream before we
// drop it; this proves the call is live.
*state.watch_events.lock().unwrap() = vec![DeployEvent {
sequence: 7,
observed_at: None,
time_of_last_deploy: None,
time_of_last_deploy_present: false,
object_count: 0,
attribute_count: 0,
}];
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client.watch_deploy_events(None).await.unwrap();
let event = stream
.next()
.await
.expect("bootstrap event")
.expect("ok deploy event");
assert_eq!(event.sequence, 7);
// Dropping the client-side stream must trigger the server-side stream
// future to be dropped as well, signalling cancellation.
drop(stream);
let drop_seen = tokio::time::timeout(std::time::Duration::from_secs(2), signal_rx.recv())
.await
.expect("server-side stream future was not dropped within 2s");
assert!(
drop_seen.is_some(),
"drop signal channel closed unexpectedly"
);
}
}
+8
View File
@@ -13,3 +13,11 @@ pub mod mxaccess_worker {
tonic::include_proto!("mxaccess_worker.v1");
}
}
pub mod galaxy_repository {
pub mod v1 {
#![allow(clippy::large_enum_variant)]
tonic::include_proto!("galaxy_repository.v1");
}
}
+2
View File
@@ -7,6 +7,7 @@
pub mod auth;
pub mod client;
pub mod error;
pub mod galaxy;
pub mod generated;
pub mod options;
pub mod session;
@@ -16,6 +17,7 @@ pub mod version;
pub use auth::{ApiKey, AuthInterceptor};
pub use client::{EventStream, GatewayClient};
pub use error::{CommandError, Error};
pub use galaxy::{DeployEventStream, GalaxyClient};
pub use options::ClientOptions;
pub use session::Session;
pub use value::{MxArrayProjection, MxArrayValue, MxStatus, MxValue, MxValueProjection};