feat(runtime): ClusterId scope resolution + node-scoped driver-spec parse
This commit is contained in:
@@ -18,6 +18,27 @@ public sealed record DriverInstanceSpec(
|
||||
string DriverConfig,
|
||||
string? ClusterId = null);
|
||||
|
||||
/// <summary>How a node should scope a deployment artifact to its own ClusterId.</summary>
|
||||
public enum ClusterFilterMode
|
||||
{
|
||||
/// <summary>Apply everything (single-cluster / legacy deployments).</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Filter the artifact to the node's own ClusterId.</summary>
|
||||
ScopeTo,
|
||||
|
||||
/// <summary>Apply nothing (node's cluster row not found in a multi-cluster artifact).</summary>
|
||||
Suppress,
|
||||
}
|
||||
|
||||
/// <summary>Resolved scoping decision for a node against an artifact.</summary>
|
||||
/// <param name="Mode">
|
||||
/// None = apply everything (single-cluster / legacy); ScopeTo = filter to <paramref name="ClusterId"/>;
|
||||
/// Suppress = apply nothing.
|
||||
/// </param>
|
||||
/// <param name="ClusterId">The node's ClusterId when <paramref name="Mode"/> is ScopeTo; otherwise null.</param>
|
||||
public readonly record struct ClusterScope(ClusterFilterMode Mode, string? ClusterId);
|
||||
|
||||
public static class DeploymentArtifact
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -58,6 +79,73 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
||||
/// (or legacy) artifacts resolve to <see cref="ClusterFilterMode.None"/> so every existing
|
||||
/// deployment applies unchanged. In a multi-cluster artifact the node's <c>ClusterNode</c> row
|
||||
/// (matched by <paramref name="nodeId"/>) selects <see cref="ClusterFilterMode.ScopeTo"/> its
|
||||
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>. Empty /
|
||||
/// malformed blobs resolve to <see cref="ClusterFilterMode.None"/> (lenient, matching the
|
||||
/// other parsers).
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to inspect.</param>
|
||||
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") to match against <c>Nodes</c>.</param>
|
||||
/// <returns>The resolved <see cref="ClusterScope"/> decision for this node.</returns>
|
||||
public static ClusterScope ResolveClusterScope(ReadOnlySpan<byte> blob, string nodeId)
|
||||
{
|
||||
if (blob.IsEmpty) return new ClusterScope(ClusterFilterMode.None, null);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
var clusterCount = root.TryGetProperty("Clusters", out var cl) && cl.ValueKind == JsonValueKind.Array
|
||||
? cl.GetArrayLength() : 0;
|
||||
if (clusterCount <= 1) return new ClusterScope(ClusterFilterMode.None, null);
|
||||
|
||||
string? myCluster = null;
|
||||
if (root.TryGetProperty("Nodes", out var nodes) && nodes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in nodes.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var nid = el.TryGetProperty("NodeId", out var nEl) ? nEl.GetString() : null;
|
||||
if (!string.Equals(nid, nodeId, StringComparison.Ordinal)) continue;
|
||||
myCluster = el.TryGetProperty("ClusterId", out var cEl) ? cEl.GetString() : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return string.IsNullOrWhiteSpace(myCluster)
|
||||
? new ClusterScope(ClusterFilterMode.Suppress, null)
|
||||
: new ClusterScope(ClusterFilterMode.ScopeTo, myCluster);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new ClusterScope(ClusterFilterMode.None, null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a deployment artifact blob into the driver-instance specs a specific node should
|
||||
/// spawn, scoped to its own ClusterId via <see cref="ResolveClusterScope"/>. Single-cluster /
|
||||
/// legacy artifacts return every spec; a multi-cluster artifact returns only the matching
|
||||
/// cluster's specs (or none when the node's row is absent).
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to parse.</param>
|
||||
/// <param name="nodeId">The node's identity (e.g. "central-1:4053") used to resolve cluster scope.</param>
|
||||
/// <returns>The driver-instance specs this node should spawn.</returns>
|
||||
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob, string nodeId)
|
||||
{
|
||||
var scope = ResolveClusterScope(blob, nodeId);
|
||||
var all = ParseDriverInstances(blob);
|
||||
return scope.Mode switch
|
||||
{
|
||||
ClusterFilterMode.Suppress => Array.Empty<DriverInstanceSpec>(),
|
||||
ClusterFilterMode.ScopeTo => all.Where(
|
||||
s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.Ordinal)).ToArray(),
|
||||
_ => all,
|
||||
};
|
||||
}
|
||||
|
||||
private static DriverInstanceSpec? TryReadSpec(JsonElement el)
|
||||
{
|
||||
var rowId = el.TryGetProperty("DriverInstanceRowId", out var rowEl)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
@@ -8,6 +9,78 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
||||
|
||||
public sealed class DeploymentArtifactTests
|
||||
{
|
||||
private static byte[] BlobOf(object snapshot) =>
|
||||
System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(snapshot);
|
||||
|
||||
private static object MultiClusterSnapshot() => new
|
||||
{
|
||||
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
|
||||
Nodes = new[]
|
||||
{
|
||||
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
|
||||
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
|
||||
},
|
||||
DriverInstances = new[]
|
||||
{
|
||||
new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "main-galaxy", Name = "g", DriverType = "GalaxyMxGateway", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
|
||||
new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "sa-modbus", Name = "m", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Verifies a single-cluster artifact resolves to None (apply everything).</summary>
|
||||
[Fact]
|
||||
public void ResolveClusterScope_single_cluster_artifact_returns_None()
|
||||
{
|
||||
var blob = BlobOf(new { Clusters = new[] { new { ClusterId = "MAIN" } }, Nodes = Array.Empty<object>() });
|
||||
var scope = DeploymentArtifact.ResolveClusterScope(blob, "central-1:4053");
|
||||
scope.Mode.ShouldBe(ClusterFilterMode.None);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a multi-cluster artifact scopes a known node to its own ClusterId.</summary>
|
||||
[Fact]
|
||||
public void ResolveClusterScope_multi_cluster_known_node_scopes_to_its_cluster()
|
||||
{
|
||||
var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "site-a-1:4053");
|
||||
scope.Mode.ShouldBe(ClusterFilterMode.ScopeTo);
|
||||
scope.ClusterId.ShouldBe("SITE-A");
|
||||
}
|
||||
|
||||
/// <summary>Verifies a multi-cluster artifact suppresses an unknown node.</summary>
|
||||
[Fact]
|
||||
public void ResolveClusterScope_multi_cluster_unknown_node_suppresses()
|
||||
{
|
||||
var scope = DeploymentArtifact.ResolveClusterScope(BlobOf(MultiClusterSnapshot()), "ghost-9:4053");
|
||||
scope.Mode.ShouldBe(ClusterFilterMode.Suppress);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the scoped parse returns only the node's own cluster's drivers.</summary>
|
||||
[Fact]
|
||||
public void ParseDriverInstances_scoped_returns_only_my_clusters_drivers()
|
||||
{
|
||||
var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "central-1:4053");
|
||||
specs.Select(s => s.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies the scoped parse returns nothing for an unknown node.</summary>
|
||||
[Fact]
|
||||
public void ParseDriverInstances_scoped_unknown_node_returns_empty()
|
||||
{
|
||||
var specs = DeploymentArtifact.ParseDriverInstances(BlobOf(MultiClusterSnapshot()), "ghost-9:4053");
|
||||
specs.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Verifies the scoped parse returns all drivers for a single-cluster artifact.</summary>
|
||||
[Fact]
|
||||
public void ParseDriverInstances_scoped_single_cluster_returns_all()
|
||||
{
|
||||
var blob = BlobOf(new
|
||||
{
|
||||
Clusters = new[] { new { ClusterId = "MAIN" } },
|
||||
Nodes = new[] { new { NodeId = "n1:4053", ClusterId = "MAIN" } },
|
||||
DriverInstances = new[] { new { DriverInstanceRowId = Guid.NewGuid(), DriverInstanceId = "d1", Name = "d", DriverType = "Modbus", Enabled = true, DriverConfig = "{}", ClusterId = "MAIN" } },
|
||||
});
|
||||
DeploymentArtifact.ParseDriverInstances(blob, "anything:4053").Select(s => s.DriverInstanceId).ShouldBe(new[] { "d1" });
|
||||
}
|
||||
/// <summary>Verifies that empty blob returns empty list.</summary>
|
||||
[Fact]
|
||||
public void Empty_blob_returns_empty_list()
|
||||
|
||||
Reference in New Issue
Block a user