Fix Galaxy projection and constraint review findings
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MxGateway.Server.Configuration;
|
using MxGateway.Server.Configuration;
|
||||||
using MxGateway.Server.Galaxy;
|
using MxGateway.Server.Galaxy;
|
||||||
@@ -21,8 +23,12 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly DateTimeOffset _gatewayStartedAt;
|
private readonly DateTimeOffset _gatewayStartedAt;
|
||||||
private readonly TimeSpan _snapshotInterval;
|
private readonly TimeSpan _snapshotInterval;
|
||||||
|
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
|
||||||
private readonly int _recentFaultLimit;
|
private readonly int _recentFaultLimit;
|
||||||
private readonly int _recentSessionLimit;
|
private readonly int _recentSessionLimit;
|
||||||
|
private readonly ILogger<DashboardSnapshotService> _logger;
|
||||||
|
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
|
||||||
|
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
|
||||||
|
|
||||||
public DashboardSnapshotService(
|
public DashboardSnapshotService(
|
||||||
ISessionRegistry sessionRegistry,
|
ISessionRegistry sessionRegistry,
|
||||||
@@ -31,7 +37,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
IGalaxyHierarchyCache galaxyHierarchyCache,
|
IGalaxyHierarchyCache galaxyHierarchyCache,
|
||||||
IApiKeyAdminStore apiKeyAdminStore,
|
IApiKeyAdminStore apiKeyAdminStore,
|
||||||
IOptions<GatewayOptions> options,
|
IOptions<GatewayOptions> options,
|
||||||
TimeProvider? timeProvider = null)
|
TimeProvider? timeProvider = null,
|
||||||
|
ILogger<DashboardSnapshotService>? logger = null)
|
||||||
{
|
{
|
||||||
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||||
@@ -45,6 +52,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||||
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||||
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||||
|
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DashboardSnapshot GetSnapshot()
|
public DashboardSnapshot GetSnapshot()
|
||||||
@@ -73,7 +81,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
Workers: workerSummaries,
|
Workers: workerSummaries,
|
||||||
Metrics: CreateMetricSummaries(metricsSnapshot),
|
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||||
Faults: CreateFaultSummaries(sessions, generatedAt),
|
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||||
ApiKeys: CreateApiKeySummaries(),
|
ApiKeys: Volatile.Read(ref _apiKeySummaries),
|
||||||
Configuration: _configurationProvider.GetEffectiveConfiguration(),
|
Configuration: _configurationProvider.GetEffectiveConfiguration(),
|
||||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||||
}
|
}
|
||||||
@@ -86,6 +94,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
yield return GetSnapshot();
|
yield return GetSnapshot();
|
||||||
|
|
||||||
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||||
@@ -106,6 +115,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
yield return GetSnapshot();
|
yield return GetSnapshot();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,13 +207,19 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IReadOnlyList<DashboardApiKeySummary> CreateApiKeySummaries()
|
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _apiKeyAdminStore.ListAsync(CancellationToken.None)
|
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
.GetAwaiter()
|
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
|
||||||
.GetResult()
|
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
|
||||||
|
.ConfigureAwait(false))
|
||||||
.Select(key => new DashboardApiKeySummary(
|
.Select(key => new DashboardApiKeySummary(
|
||||||
KeyId: key.KeyId,
|
KeyId: key.KeyId,
|
||||||
DisplayName: key.DisplayName,
|
DisplayName: key.DisplayName,
|
||||||
@@ -213,10 +229,26 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
LastUsedUtc: key.LastUsedUtc,
|
LastUsedUtc: key.LastUsedUtc,
|
||||||
RevokedUtc: key.RevokedUtc))
|
RevokedUtc: key.RevokedUtc))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
Volatile.Write(ref _apiKeySummaries, summaries);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Timed out refreshing dashboard API key summaries after {Timeout}.",
|
||||||
|
_apiKeySummaryRefreshTimeout);
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return Array.Empty<DashboardApiKeySummary>();
|
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_apiKeySummaryRefreshGate.Release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||||
|
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||||
|
|
||||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||||
int historized = attributes.Count(row => row.IsHistorized);
|
int historized = attributes.Count(row => row.IsHistorized);
|
||||||
@@ -146,6 +147,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
LastDeployTime: deployTime,
|
LastDeployTime: deployTime,
|
||||||
LastError: null,
|
LastError: null,
|
||||||
Objects: objects,
|
Objects: objects,
|
||||||
|
Index: index,
|
||||||
DashboardSummary: dashboardSummary,
|
DashboardSummary: dashboardSummary,
|
||||||
ObjectCount: hierarchy.Count,
|
ObjectCount: hierarchy.Count,
|
||||||
AreaCount: areaCount,
|
AreaCount: areaCount,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed record GalaxyHierarchyCacheEntry(
|
|||||||
DateTimeOffset? LastDeployTime,
|
DateTimeOffset? LastDeployTime,
|
||||||
string? LastError,
|
string? LastError,
|
||||||
IReadOnlyList<GalaxyObject> Objects,
|
IReadOnlyList<GalaxyObject> Objects,
|
||||||
|
GalaxyHierarchyIndex Index,
|
||||||
DashboardGalaxySummary DashboardSummary,
|
DashboardGalaxySummary DashboardSummary,
|
||||||
int ObjectCount,
|
int ObjectCount,
|
||||||
int AreaCount,
|
int AreaCount,
|
||||||
@@ -31,6 +32,7 @@ public sealed record GalaxyHierarchyCacheEntry(
|
|||||||
LastDeployTime: null,
|
LastDeployTime: null,
|
||||||
LastError: null,
|
LastError: null,
|
||||||
Objects: Array.Empty<GalaxyObject>(),
|
Objects: Array.Empty<GalaxyObject>(),
|
||||||
|
Index: GalaxyHierarchyIndex.Empty,
|
||||||
DashboardSummary: DashboardGalaxySummary.Unknown,
|
DashboardSummary: DashboardGalaxySummary.Unknown,
|
||||||
ObjectCount: 0,
|
ObjectCount: 0,
|
||||||
AreaCount: 0,
|
AreaCount: 0,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
public sealed class GalaxyHierarchyIndex
|
||||||
|
{
|
||||||
|
private GalaxyHierarchyIndex(
|
||||||
|
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||||
|
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||||
|
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
|
||||||
|
{
|
||||||
|
ObjectViews = objectViews;
|
||||||
|
ObjectViewsById = objectViewsById;
|
||||||
|
TagsByAddress = tagsByAddress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GalaxyHierarchyIndex Empty { get; } = new(
|
||||||
|
Array.Empty<GalaxyObjectView>(),
|
||||||
|
new Dictionary<int, GalaxyObjectView>(),
|
||||||
|
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
|
||||||
|
|
||||||
|
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
|
||||||
|
{
|
||||||
|
if (objects.Count == 0)
|
||||||
|
{
|
||||||
|
return Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dictionary<int, GalaxyObject> objectsById = new();
|
||||||
|
foreach (GalaxyObject obj in objects)
|
||||||
|
{
|
||||||
|
objectsById.TryAdd(obj.GobjectId, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<GalaxyObjectView> views = new(objects.Count);
|
||||||
|
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||||
|
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (GalaxyObject obj in objects)
|
||||||
|
{
|
||||||
|
string path = BuildContainedPath(obj, objectsById);
|
||||||
|
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
|
||||||
|
GalaxyObjectView view = new(obj, path, depth);
|
||||||
|
views.Add(view);
|
||||||
|
viewsById.TryAdd(obj.GobjectId, view);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||||
|
{
|
||||||
|
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
|
||||||
|
{
|
||||||
|
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GalaxyHierarchyIndex(
|
||||||
|
views,
|
||||||
|
viewsById,
|
||||||
|
tagsByAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildContainedPath(
|
||||||
|
GalaxyObject obj,
|
||||||
|
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
||||||
|
{
|
||||||
|
Stack<string> names = new();
|
||||||
|
HashSet<int> seen = [];
|
||||||
|
GalaxyObject? current = obj;
|
||||||
|
while (current is not null && seen.Add(current.GobjectId))
|
||||||
|
{
|
||||||
|
names.Push(ResolvePathSegment(current));
|
||||||
|
current = current.ParentGobjectId != 0
|
||||||
|
&& objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
|
||||||
|
? parent
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolvePathSegment(GalaxyObject obj)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||||
|
{
|
||||||
|
return obj.ContainedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||||
|
{
|
||||||
|
return obj.BrowseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.TagName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,36 @@ public static class GalaxyHierarchyProjector
|
|||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
||||||
|
{
|
||||||
|
return Project(
|
||||||
|
entry,
|
||||||
|
request,
|
||||||
|
browseSubtreeGlobs,
|
||||||
|
offset: 0,
|
||||||
|
pageSize: int.MaxValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
|
GalaxyHierarchyCacheEntry entry,
|
||||||
|
DiscoverHierarchyRequest request,
|
||||||
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
int offset,
|
||||||
|
int pageSize)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(entry);
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
if (offset < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||||
|
}
|
||||||
|
|
||||||
IReadOnlyList<ObjectView> views = BuildViews(entry.Objects);
|
if (pageSize <= 0)
|
||||||
ObjectView? root = ResolveRoot(request, views);
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||||
|
}
|
||||||
|
|
||||||
|
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||||
|
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||||
int? maxDepth = request.MaxDepth;
|
int? maxDepth = request.MaxDepth;
|
||||||
if (maxDepth < 0)
|
if (maxDepth < 0)
|
||||||
{
|
{
|
||||||
@@ -25,8 +49,10 @@ public static class GalaxyHierarchyProjector
|
|||||||
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<GalaxyObject> filtered = [];
|
List<GalaxyObject> page = [];
|
||||||
foreach (ObjectView view in views)
|
int matchedCount = 0;
|
||||||
|
bool includeAttributes = IncludeAttributes(request);
|
||||||
|
foreach (GalaxyObjectView view in views)
|
||||||
{
|
{
|
||||||
if (!MatchesRoot(view, root, maxDepth)
|
if (!MatchesRoot(view, root, maxDepth)
|
||||||
|| !MatchesBrowseSubtrees(view, browseSubtreeGlobs)
|
|| !MatchesBrowseSubtrees(view, browseSubtreeGlobs)
|
||||||
@@ -35,12 +61,17 @@ public static class GalaxyHierarchyProjector
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.Add(CloneObject(view.Object, IncludeAttributes(request)));
|
if (matchedCount >= offset && page.Count < pageSize)
|
||||||
|
{
|
||||||
|
page.Add(CloneObject(view.Object, includeAttributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new GalaxyHierarchyQueryResult(
|
return new GalaxyHierarchyQueryResult(
|
||||||
filtered,
|
page,
|
||||||
filtered.Count,
|
matchedCount,
|
||||||
ComputeFilterSignature(request, browseSubtreeGlobs));
|
ComputeFilterSignature(request, browseSubtreeGlobs));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,23 +84,9 @@ public static class GalaxyHierarchyProjector
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (GalaxyObject obj in entry.Objects)
|
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||||
{
|
? lookup.Object
|
||||||
if (string.Equals(obj.TagName, tagAddress, StringComparison.OrdinalIgnoreCase))
|
: null;
|
||||||
{
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
|
||||||
{
|
|
||||||
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static GalaxyAttribute? FindAttributeForTag(
|
public static GalaxyAttribute? FindAttributeForTag(
|
||||||
@@ -81,81 +98,25 @@ public static class GalaxyHierarchyProjector
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (GalaxyObject obj in entry.Objects)
|
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||||
{
|
? lookup.Attribute
|
||||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
: null;
|
||||||
{
|
|
||||||
if (string.Equals(attribute.FullTagReference, tagAddress, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return attribute;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetContainedPath(
|
public static string GetContainedPath(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
int gobjectId)
|
int gobjectId)
|
||||||
{
|
{
|
||||||
return BuildViews(entry.Objects)
|
return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
|
||||||
.FirstOrDefault(view => view.Object.GobjectId == gobjectId)
|
? view.ContainedPath
|
||||||
?.ContainedPath ?? string.Empty;
|
: string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<ObjectView> BuildViews(IReadOnlyList<GalaxyObject> objects)
|
private static GalaxyObjectView? ResolveRoot(
|
||||||
{
|
|
||||||
Dictionary<int, GalaxyObject> byId = objects.ToDictionary(obj => obj.GobjectId);
|
|
||||||
List<ObjectView> views = new(objects.Count);
|
|
||||||
foreach (GalaxyObject obj in objects)
|
|
||||||
{
|
|
||||||
string path = BuildContainedPath(obj, byId);
|
|
||||||
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
|
|
||||||
views.Add(new ObjectView(obj, path, depth));
|
|
||||||
}
|
|
||||||
|
|
||||||
return views;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string BuildContainedPath(
|
|
||||||
GalaxyObject obj,
|
|
||||||
IReadOnlyDictionary<int, GalaxyObject> byId)
|
|
||||||
{
|
|
||||||
Stack<string> names = new();
|
|
||||||
HashSet<int> seen = [];
|
|
||||||
GalaxyObject? current = obj;
|
|
||||||
while (current is not null && seen.Add(current.GobjectId))
|
|
||||||
{
|
|
||||||
names.Push(ResolvePathSegment(current));
|
|
||||||
current = current.ParentGobjectId != 0 && byId.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
|
|
||||||
? parent
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ResolvePathSegment(GalaxyObject obj)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
|
||||||
{
|
|
||||||
return obj.ContainedName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
|
||||||
{
|
|
||||||
return obj.BrowseName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return obj.TagName;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ObjectView? ResolveRoot(
|
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
IReadOnlyList<ObjectView> views)
|
IReadOnlyList<GalaxyObjectView> views)
|
||||||
{
|
{
|
||||||
ObjectView? root = request.RootCase switch
|
GalaxyObjectView? root = request.RootCase switch
|
||||||
{
|
{
|
||||||
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
|
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
|
||||||
@@ -176,8 +137,8 @@ public static class GalaxyHierarchyProjector
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesRoot(
|
private static bool MatchesRoot(
|
||||||
ObjectView view,
|
GalaxyObjectView view,
|
||||||
ObjectView? root,
|
GalaxyObjectView? root,
|
||||||
int? maxDepth)
|
int? maxDepth)
|
||||||
{
|
{
|
||||||
if (root is null)
|
if (root is null)
|
||||||
@@ -196,7 +157,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesBrowseSubtrees(
|
private static bool MatchesBrowseSubtrees(
|
||||||
ObjectView view,
|
GalaxyObjectView view,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
{
|
{
|
||||||
return browseSubtreeGlobs is null
|
return browseSubtreeGlobs is null
|
||||||
@@ -256,7 +217,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ComputeFilterSignature(
|
public static string ComputeFilterSignature(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
{
|
{
|
||||||
@@ -282,6 +243,4 @@ public static class GalaxyHierarchyProjector
|
|||||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||||
return Convert.ToHexString(hash, 0, 12);
|
return Convert.ToHexString(hash, 0, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record ObjectView(GalaxyObject Object, string ContainedPath, int Depth);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
public sealed record GalaxyObjectView(
|
||||||
|
GalaxyObject Object,
|
||||||
|
string ContainedPath,
|
||||||
|
int Depth);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
|
namespace MxGateway.Server.Galaxy;
|
||||||
|
|
||||||
|
public sealed record GalaxyTagLookup(
|
||||||
|
GalaxyObject Object,
|
||||||
|
GalaxyAttribute? Attribute,
|
||||||
|
string ContainedPath);
|
||||||
@@ -71,34 +71,32 @@ public sealed class GalaxyRepositoryGrpcService(
|
|||||||
ResolveUnavailableMessage(entry)));
|
ResolveUnavailableMessage(entry)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int pageSize = ResolvePageSize(request.PageSize);
|
||||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||||
|
string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
|
||||||
|
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||||
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
|
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
|
||||||
entry,
|
entry,
|
||||||
request,
|
request,
|
||||||
browseSubtrees);
|
browseSubtrees,
|
||||||
|
pageToken.Offset,
|
||||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, query.FilterSignature);
|
pageSize);
|
||||||
int offset = pageToken.Offset;
|
int offset = pageToken.Offset;
|
||||||
if (offset > query.Objects.Count)
|
if (offset > query.TotalObjectCount)
|
||||||
{
|
{
|
||||||
throw new RpcException(new Status(
|
throw new RpcException(new Status(
|
||||||
StatusCode.InvalidArgument,
|
StatusCode.InvalidArgument,
|
||||||
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
||||||
}
|
}
|
||||||
|
|
||||||
int pageSize = ResolvePageSize(request.PageSize);
|
|
||||||
int take = Math.Min(pageSize, query.Objects.Count - offset);
|
|
||||||
DiscoverHierarchyReply reply = new()
|
DiscoverHierarchyReply reply = new()
|
||||||
{
|
{
|
||||||
TotalObjectCount = query.TotalObjectCount,
|
TotalObjectCount = query.TotalObjectCount,
|
||||||
};
|
};
|
||||||
for (int index = offset; index < offset + take; index++)
|
reply.Objects.Add(query.Objects);
|
||||||
{
|
|
||||||
reply.Objects.Add(query.Objects[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
int nextOffset = offset + take;
|
int nextOffset = offset + query.Objects.Count;
|
||||||
if (nextOffset < query.Objects.Count)
|
if (nextOffset < query.TotalObjectCount)
|
||||||
{
|
{
|
||||||
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,22 +62,20 @@ public sealed class ConstraintEnforcer(
|
|||||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
|
||||||
}
|
}
|
||||||
|
|
||||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
GalaxyTagLookup? target = ResolveTarget(registration.TagAddress);
|
||||||
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, registration.TagAddress);
|
if (target is null)
|
||||||
if (obj is null)
|
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."));
|
||||||
}
|
}
|
||||||
|
|
||||||
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId);
|
if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
|
||||||
if (!MatchesPathOrTag(containedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs))
|
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("write_scope", "Tag is outside the API key write scope."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.MaxWriteClassification is { } maxClassification)
|
if (constraints.MaxWriteClassification is { } maxClassification)
|
||||||
{
|
{
|
||||||
GalaxyAttribute? attribute = GalaxyHierarchyProjector.FindAttributeForTag(entry, registration.TagAddress);
|
GalaxyAttribute? attribute = target.Attribute;
|
||||||
if (attribute is null)
|
if (attribute is null)
|
||||||
{
|
{
|
||||||
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
|
||||||
@@ -115,32 +113,39 @@ public sealed class ConstraintEnforcer(
|
|||||||
ApiKeyConstraints constraints,
|
ApiKeyConstraints constraints,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
{
|
{
|
||||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
GalaxyTagLookup? target = ResolveTarget(tagAddress);
|
||||||
GalaxyObject? obj = GalaxyHierarchyProjector.FindObjectForTag(entry, tagAddress);
|
if (target is null)
|
||||||
if (obj is null)
|
|
||||||
{
|
{
|
||||||
return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.");
|
return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string containedPath = GalaxyHierarchyProjector.GetContainedPath(entry, obj.GobjectId);
|
if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
|
||||||
if (!MatchesPathOrTag(containedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs))
|
|
||||||
{
|
{
|
||||||
return new ConstraintFailure("read_scope", "Tag is outside the API key read scope.");
|
return new ConstraintFailure("read_scope", "Tag is outside the API key read scope.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.ReadAlarmOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true })
|
||||||
{
|
{
|
||||||
return new ConstraintFailure("read_alarm_only", "Object has no alarm-bearing attributes.");
|
return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (constraints.ReadHistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true })
|
||||||
{
|
{
|
||||||
return new ConstraintFailure("read_historized_only", "Object has no historized attributes.");
|
return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private GalaxyTagLookup? ResolveTarget(string tagAddress)
|
||||||
|
{
|
||||||
|
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||||
|
return !string.IsNullOrWhiteSpace(tagAddress)
|
||||||
|
&& entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||||
|
? lookup
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool MatchesPathOrTag(
|
private static bool MatchesPathOrTag(
|
||||||
string containedPath,
|
string containedPath,
|
||||||
string tagAddress,
|
string tagAddress,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using MxGateway.Server.Galaxy;
|
using MxGateway.Server.Galaxy;
|
||||||
|
using MxGateway.Contracts.Proto.Galaxy;
|
||||||
|
|
||||||
namespace MxGateway.Tests.Galaxy;
|
namespace MxGateway.Tests.Galaxy;
|
||||||
|
|
||||||
@@ -52,6 +53,53 @@ public sealed class GalaxyHierarchyCacheTests
|
|||||||
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
|
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GalaxyHierarchyIndex_BuildsPathsAndTagLookupsWithoutThrowingOnBadMetadata()
|
||||||
|
{
|
||||||
|
GalaxyObject root = new()
|
||||||
|
{
|
||||||
|
GobjectId = 1,
|
||||||
|
TagName = "Area1",
|
||||||
|
ContainedName = "Area1",
|
||||||
|
};
|
||||||
|
GalaxyObject duplicate = new()
|
||||||
|
{
|
||||||
|
GobjectId = 1,
|
||||||
|
TagName = "DuplicateArea",
|
||||||
|
ContainedName = "DuplicateArea",
|
||||||
|
};
|
||||||
|
GalaxyObject child = new()
|
||||||
|
{
|
||||||
|
GobjectId = 2,
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
ContainedName = "Pump",
|
||||||
|
Attributes =
|
||||||
|
{
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
FullTagReference = "Pump_001.PV",
|
||||||
|
IsHistorized = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
GalaxyObject orphan = new()
|
||||||
|
{
|
||||||
|
GobjectId = 3,
|
||||||
|
ParentGobjectId = 99,
|
||||||
|
TagName = "Orphan_001",
|
||||||
|
ContainedName = "Orphan",
|
||||||
|
};
|
||||||
|
|
||||||
|
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, duplicate, child, orphan]);
|
||||||
|
|
||||||
|
Assert.Equal("Area1/Pump", index.ObjectViewsById[2].ContainedPath);
|
||||||
|
Assert.Equal("Orphan", index.ObjectViewsById[3].ContainedPath);
|
||||||
|
Assert.Same(child, index.TagsByAddress["Pump_001.PV"].Object);
|
||||||
|
Assert.NotNull(index.TagsByAddress["Pump_001.PV"].Attribute);
|
||||||
|
Assert.Same(root, index.ObjectViewsById[1].Object);
|
||||||
|
}
|
||||||
|
|
||||||
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
|
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
|
||||||
{
|
{
|
||||||
GalaxyRepositoryOptions options = new()
|
GalaxyRepositoryOptions options = new()
|
||||||
@@ -59,7 +107,7 @@ public sealed class GalaxyHierarchyCacheTests
|
|||||||
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
|
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
|
||||||
CommandTimeoutSeconds = 1,
|
CommandTimeoutSeconds = 1,
|
||||||
};
|
};
|
||||||
GalaxyRepository repository = new(options);
|
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
|
||||||
return new GalaxyHierarchyCache(repository, notifier, clock);
|
return new GalaxyHierarchyCache(repository, notifier, clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using MxGateway.Server.Dashboard;
|
|||||||
using MxGateway.Server.Galaxy;
|
using MxGateway.Server.Galaxy;
|
||||||
using MxGateway.Server.Metrics;
|
using MxGateway.Server.Metrics;
|
||||||
using MxGateway.Server.Security.Authentication;
|
using MxGateway.Server.Security.Authentication;
|
||||||
|
using MxGateway.Server.Security.Authorization;
|
||||||
using MxGateway.Server.Sessions;
|
using MxGateway.Server.Sessions;
|
||||||
using MxGateway.Server.Workers;
|
using MxGateway.Server.Workers;
|
||||||
|
|
||||||
@@ -228,6 +229,101 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
|
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_DoesNotSynchronouslyListApiKeys()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
CountingApiKeyAdminStore apiKeyAdminStore = new();
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
new SessionRegistry(),
|
||||||
|
metrics,
|
||||||
|
apiKeyAdminStore: apiKeyAdminStore);
|
||||||
|
|
||||||
|
DashboardSnapshot snapshot = service.GetSnapshot();
|
||||||
|
|
||||||
|
Assert.Empty(snapshot.ApiKeys);
|
||||||
|
Assert.Equal(0, apiKeyAdminStore.ListCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
CountingApiKeyAdminStore apiKeyAdminStore = new(
|
||||||
|
new ApiKeyRecord(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
SecretHash: [1, 2, 3],
|
||||||
|
DisplayName: "Operator",
|
||||||
|
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||||
|
Constraints: ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
BrowseSubtrees = ["Area1/*"],
|
||||||
|
},
|
||||||
|
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||||
|
LastUsedUtc: null,
|
||||||
|
RevokedUtc: null));
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
new SessionRegistry(),
|
||||||
|
metrics,
|
||||||
|
apiKeyAdminStore: apiKeyAdminStore);
|
||||||
|
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||||
|
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||||
|
.WatchSnapshotsAsync(cancellation.Token)
|
||||||
|
.GetAsyncEnumerator(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(await enumerator.MoveNextAsync());
|
||||||
|
DashboardSnapshot snapshot = enumerator.Current;
|
||||||
|
|
||||||
|
DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys);
|
||||||
|
Assert.Equal("operator01", key.KeyId);
|
||||||
|
Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees);
|
||||||
|
Assert.Equal(1, apiKeyAdminStore.ListCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
SequencedApiKeyAdminStore apiKeyAdminStore = new(
|
||||||
|
new ApiKeyRecord(
|
||||||
|
KeyId: "operator01",
|
||||||
|
KeyPrefix: "mxgw_operator01",
|
||||||
|
SecretHash: [1, 2, 3],
|
||||||
|
DisplayName: "Operator",
|
||||||
|
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||||
|
Constraints: ApiKeyConstraints.Empty,
|
||||||
|
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||||
|
LastUsedUtc: null,
|
||||||
|
RevokedUtc: null));
|
||||||
|
DashboardSnapshotService service = CreateService(
|
||||||
|
new SessionRegistry(),
|
||||||
|
metrics,
|
||||||
|
new GatewayOptions
|
||||||
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
|
{
|
||||||
|
SnapshotIntervalMilliseconds = 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apiKeyAdminStore: apiKeyAdminStore);
|
||||||
|
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2));
|
||||||
|
await using IAsyncEnumerator<DashboardSnapshot> enumerator = service
|
||||||
|
.WatchSnapshotsAsync(cancellation.Token)
|
||||||
|
.GetAsyncEnumerator(cancellation.Token);
|
||||||
|
|
||||||
|
Assert.True(await enumerator.MoveNextAsync());
|
||||||
|
DashboardSnapshot first = enumerator.Current;
|
||||||
|
apiKeyAdminStore.FailNext = true;
|
||||||
|
|
||||||
|
Assert.True(await enumerator.MoveNextAsync());
|
||||||
|
DashboardSnapshot second = enumerator.Current;
|
||||||
|
|
||||||
|
Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId);
|
||||||
|
Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId);
|
||||||
|
Assert.Equal(2, apiKeyAdminStore.ListCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
|
||||||
{
|
{
|
||||||
@@ -258,7 +354,8 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
SessionRegistry registry,
|
SessionRegistry registry,
|
||||||
GatewayMetrics metrics,
|
GatewayMetrics metrics,
|
||||||
GatewayOptions? options = null,
|
GatewayOptions? options = null,
|
||||||
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
|
IGalaxyHierarchyCache? galaxyHierarchyCache = null,
|
||||||
|
IApiKeyAdminStore? apiKeyAdminStore = null)
|
||||||
{
|
{
|
||||||
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
GatewayOptions resolvedOptions = options ?? new GatewayOptions
|
||||||
{
|
{
|
||||||
@@ -274,7 +371,7 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
metrics,
|
metrics,
|
||||||
configurationProvider,
|
configurationProvider,
|
||||||
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
|
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
|
||||||
new FakeApiKeyAdminStore(),
|
apiKeyAdminStore ?? new FakeApiKeyAdminStore(),
|
||||||
Options.Create(resolvedOptions));
|
Options.Create(resolvedOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,14 +384,14 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
|
private class FakeApiKeyAdminStore : IApiKeyAdminStore
|
||||||
{
|
{
|
||||||
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
|
||||||
}
|
}
|
||||||
@@ -317,6 +414,34 @@ public sealed class DashboardSnapshotServiceTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
|
||||||
|
{
|
||||||
|
public int ListCount { get; protected set; }
|
||||||
|
|
||||||
|
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ListCount++;
|
||||||
|
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
|
||||||
|
{
|
||||||
|
public bool FailNext { get; set; }
|
||||||
|
|
||||||
|
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (FailNext)
|
||||||
|
{
|
||||||
|
FailNext = false;
|
||||||
|
ListCount++;
|
||||||
|
throw new InvalidOperationException("Simulated SQLite failure.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.ListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static GatewaySession CreateSession(
|
private static GatewaySession CreateSession(
|
||||||
string sessionId,
|
string sessionId,
|
||||||
string? clientIdentity,
|
string? clientIdentity,
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ public sealed class GalaxyRepositoryGrpcServiceTests
|
|||||||
Sequence = 7,
|
Sequence = 7,
|
||||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||||
Objects = objects,
|
Objects = objects,
|
||||||
|
Index = GalaxyHierarchyIndex.Build(objects),
|
||||||
DashboardSummary = DashboardGalaxySummary.Unknown with
|
DashboardSummary = DashboardGalaxySummary.Unknown with
|
||||||
{
|
{
|
||||||
Status = DashboardGalaxyStatus.Healthy,
|
Status = DashboardGalaxyStatus.Healthy,
|
||||||
|
|||||||
@@ -70,6 +70,60 @@ public sealed class ConstraintEnforcerTests
|
|||||||
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
|
Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||||
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
ReadHistorizedOnly = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"Pump_001.NonHistorized",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(failure);
|
||||||
|
Assert.Equal("read_historized_only", failure.ConstraintName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||||
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
ReadAlarmOnly = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"Pump_001.PV",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(failure);
|
||||||
|
Assert.Equal("read_alarm_only", failure.ConstraintName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||||
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
ReadHistorizedOnly = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"Pump_001",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(failure);
|
||||||
|
Assert.Equal("read_historized_only", failure.ConstraintName);
|
||||||
|
}
|
||||||
|
|
||||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
|
private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore)
|
||||||
{
|
{
|
||||||
auditStore = new FakeAuditStore();
|
auditStore = new FakeAuditStore();
|
||||||
@@ -105,49 +159,63 @@ public sealed class ConstraintEnforcerTests
|
|||||||
|
|
||||||
private static GalaxyHierarchyCacheEntry CreateEntry()
|
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||||
{
|
{
|
||||||
return GalaxyHierarchyCacheEntry.Empty with
|
IReadOnlyList<GalaxyObject> objects =
|
||||||
{
|
[
|
||||||
Status = GalaxyCacheStatus.Healthy,
|
new GalaxyObject
|
||||||
Objects =
|
{
|
||||||
[
|
GobjectId = 1,
|
||||||
new GalaxyObject
|
TagName = "Area1",
|
||||||
|
ContainedName = "Area1",
|
||||||
|
},
|
||||||
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 2,
|
||||||
|
TagName = "Pump_001",
|
||||||
|
ContainedName = "Pump",
|
||||||
|
ParentGobjectId = 1,
|
||||||
|
Attributes =
|
||||||
{
|
{
|
||||||
GobjectId = 1,
|
new GalaxyAttribute
|
||||||
TagName = "Area1",
|
|
||||||
ContainedName = "Area1",
|
|
||||||
},
|
|
||||||
new GalaxyObject
|
|
||||||
{
|
|
||||||
GobjectId = 2,
|
|
||||||
TagName = "Pump_001",
|
|
||||||
ContainedName = "Pump",
|
|
||||||
ParentGobjectId = 1,
|
|
||||||
Attributes =
|
|
||||||
{
|
|
||||||
new GalaxyAttribute
|
|
||||||
{
|
{
|
||||||
AttributeName = "PV",
|
AttributeName = "PV",
|
||||||
FullTagReference = "Pump_001.PV",
|
FullTagReference = "Pump_001.PV",
|
||||||
SecurityClassification = 2,
|
SecurityClassification = 2,
|
||||||
IsHistorized = true,
|
IsHistorized = true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
new GalaxyObject
|
|
||||||
{
|
|
||||||
GobjectId = 3,
|
|
||||||
TagName = "Other_001",
|
|
||||||
ContainedName = "Other",
|
|
||||||
Attributes =
|
|
||||||
{
|
|
||||||
new GalaxyAttribute
|
new GalaxyAttribute
|
||||||
{
|
{
|
||||||
AttributeName = "PV",
|
AttributeName = "Alarm",
|
||||||
FullTagReference = "Other_001.PV",
|
FullTagReference = "Pump_001.Alarm",
|
||||||
|
IsAlarm = true,
|
||||||
|
},
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "NonHistorized",
|
||||||
|
FullTagReference = "Pump_001.NonHistorized",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
new GalaxyObject
|
||||||
|
{
|
||||||
|
GobjectId = 3,
|
||||||
|
TagName = "Other_001",
|
||||||
|
ContainedName = "Other",
|
||||||
|
Attributes =
|
||||||
|
{
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
AttributeName = "PV",
|
||||||
|
FullTagReference = "Other_001.PV",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return GalaxyHierarchyCacheEntry.Empty with
|
||||||
|
{
|
||||||
|
Status = GalaxyCacheStatus.Healthy,
|
||||||
|
Objects = objects,
|
||||||
|
Index = GalaxyHierarchyIndex.Build(objects),
|
||||||
DashboardSummary = DashboardGalaxySummary.Unknown,
|
DashboardSummary = DashboardGalaxySummary.Unknown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user