refactor(runtime): ParsedDeploymentArtifact — one JsonDocument per apply
Every public entry point on DeploymentArtifact took ReadOnlySpan<byte> and did JsonDocument.Parse(blob.ToArray()) — a full copy plus a full document build per call — so one apply paid ~8-11 full parses of the same bytes (the scoped ParseComposition alone parsed three times: scope, composition, BuildClusterSets). DeploymentArtifact.Open(byte[]) now parses once into ParsedDeploymentArtifact, which owns the JsonDocument and exposes an instance method mirroring each static entry point. The moved bodies operate on Root; BuildClusterSets takes JsonElement instead of re-parsing the blob; ResolveClusterScope is memoized per node id, since every consumer of one apply asks about the same node. Every static is now a thin wrapper (Open + delegate + dispose) keeping its ReadOnlySpan<byte> signature and its lenient empty/malformed contract — the wrapper pays the one copy and owns the JsonException catch, so output shapes are unchanged. Hot callers migrate to Open() separately. ParsedDeploymentArtifactTests pins same-bytes-same-output through both doors for all six entry points over a rich multi-cluster artifact (raw chain + UNS refs + scripts + keys + ACLs), with falsifiability controls proving the fixture actually scopes, plus memoization and after-dispose-use coverage.
This commit is contained in:
@@ -73,6 +73,14 @@ public sealed record DataPlaneAuthorityProjection(
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// Static, blob-in / result-out facade over <see cref="ParsedDeploymentArtifact"/>. Every entry point
|
||||
/// here parses the blob, answers ONE question, and throws the parsed document away — so an apply that
|
||||
/// asks several questions of the same bytes pays for several full <see cref="JsonDocument"/> builds.
|
||||
/// Prefer <see cref="Open"/> when more than one answer is needed from the same artifact; these wrappers
|
||||
/// exist so the one-shot call sites (and every existing test) keep their exact shape and their lenient
|
||||
/// empty/malformed contract.
|
||||
/// </summary>
|
||||
public static class DeploymentArtifact
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -80,6 +88,19 @@ public static class DeploymentArtifact
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parse <paramref name="blob"/> ONCE into a reusable <see cref="ParsedDeploymentArtifact"/>, so
|
||||
/// every consumer of a single apply reads the same <see cref="JsonDocument"/> instead of rebuilding
|
||||
/// it per question. Dispose it when the apply completes.
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact bytes. Not copied — the returned artifact reads directly
|
||||
/// from this array, which must not be mutated for its lifetime (artifact blobs never are: they are
|
||||
/// deployment-immutable bytes read from SQL, the LocalDb cache, or a config fetch).</param>
|
||||
/// <returns>The parsed artifact. The caller owns it and must dispose it.</returns>
|
||||
/// <exception cref="JsonException">The blob is not valid JSON. Unlike the static wrappers — which own
|
||||
/// the lenient "malformed ⇒ empty result" contract — opening is explicit about unusable bytes.</exception>
|
||||
public static ParsedDeploymentArtifact Open(byte[] blob) => new(JsonDocument.Parse(blob));
|
||||
|
||||
/// <summary>
|
||||
/// Parse a deployment artifact blob into the list of driver-instance specs to spawn.
|
||||
/// Empty / malformed blobs return an empty list — callers log + treat as "no drivers".
|
||||
@@ -89,32 +110,10 @@ public static class DeploymentArtifact
|
||||
public static IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("DriverInstances", out var arr)
|
||||
|| arr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
|
||||
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
|
||||
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
|
||||
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
|
||||
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
|
||||
var rawTagsByDriver = BuildRawTagsByDriver(root);
|
||||
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
||||
|
||||
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
|
||||
if (spec is not null) result.Add(spec);
|
||||
}
|
||||
return result;
|
||||
using var parsed = Open(blob.ToArray());
|
||||
return parsed.ParseDriverInstances();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
@@ -122,6 +121,195 @@ 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. a "host:port" string) 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 parsed = Open(blob.ToArray());
|
||||
return parsed.ResolveClusterScope(nodeId);
|
||||
}
|
||||
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. a "host:port" string) 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)
|
||||
{
|
||||
if (blob.IsEmpty) return Array.Empty<DriverInstanceSpec>();
|
||||
try
|
||||
{
|
||||
using var parsed = Open(blob.ToArray());
|
||||
return parsed.ParseDriverInstances(nodeId);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
|
||||
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
|
||||
/// permissions (#520, stage 4). See <see cref="ParsedDeploymentArtifact.ParseDataPlaneAuthority"/>
|
||||
/// for the scoping + leniency contract.
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to parse.</param>
|
||||
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
|
||||
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
|
||||
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> blob, string nodeId)
|
||||
{
|
||||
if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
|
||||
try
|
||||
{
|
||||
using var parsed = Open(blob.ToArray());
|
||||
return parsed.ParseDataPlaneAuthority(nodeId);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return DataPlaneAuthorityProjection.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the artifact into the projected <see cref="AddressSpaceComposition"/> used by
|
||||
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. Returns an empty composition for empty/
|
||||
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to parse.</param>
|
||||
/// <returns>The projected <see cref="AddressSpaceComposition"/>, or an empty composition for empty/malformed blobs.</returns>
|
||||
public static AddressSpaceComposition ParseComposition(ReadOnlySpan<byte> blob)
|
||||
{
|
||||
if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty();
|
||||
try
|
||||
{
|
||||
using var parsed = Open(blob.ToArray());
|
||||
return parsed.ParseComposition();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ParsedDeploymentArtifact.Empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
|
||||
/// its NodeId. See <see cref="ParsedDeploymentArtifact.ParseComposition(string, Action{string})"/>.</summary>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
||||
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
|
||||
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
||||
public static AddressSpaceComposition ParseComposition(
|
||||
ReadOnlySpan<byte> blob, string nodeId, Action<string>? onInconsistency = null)
|
||||
{
|
||||
if (blob.IsEmpty) return ParsedDeploymentArtifact.Empty();
|
||||
try
|
||||
{
|
||||
using var parsed = Open(blob.ToArray());
|
||||
return parsed.ParseComposition(nodeId, onInconsistency);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ParsedDeploymentArtifact.Empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deployment artifact parsed ONCE — one <see cref="JsonDocument"/> serving every consumer of a single
|
||||
/// apply (driver specs, cluster scope, data-plane authority, address-space composition), instead of the
|
||||
/// full copy-and-reparse each static entry point on <see cref="DeploymentArtifact"/> pays per call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Open it with <see cref="DeploymentArtifact.Open"/> at the top of an apply and dispose it when the
|
||||
/// apply completes. Everything it returns is fully materialised (records, lists, strings) and holds
|
||||
/// no reference back into the document, so results stay valid after disposal.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not thread-safe: the per-node <see cref="ResolveClusterScope(string)"/> memo is an unsynchronised
|
||||
/// dictionary. An apply resolves its consumers sequentially, which is the only intended use.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ParsedDeploymentArtifact : IDisposable
|
||||
{
|
||||
private readonly JsonDocument _document;
|
||||
|
||||
// Every consumer of one apply resolves the SAME node id, so this memo turns N identical scope scans
|
||||
// into one. Keyed ordinally: the scan itself matches node ids case-insensitively, so a differently
|
||||
// cased key simply misses the memo and re-scans to the same answer — never a wrong one.
|
||||
private readonly Dictionary<string, ClusterScope> _scopeByNodeId = new(StringComparer.Ordinal);
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Takes ownership of an already-parsed artifact document. Use
|
||||
/// <see cref="DeploymentArtifact.Open"/>; this exists so the document is parsed in exactly one place.</summary>
|
||||
/// <param name="document">The parsed artifact document, disposed with this instance.</param>
|
||||
internal ParsedDeploymentArtifact(JsonDocument document) => _document = document;
|
||||
|
||||
/// <summary>The artifact's root JSON object.</summary>
|
||||
public JsonElement Root => _document.RootElement;
|
||||
|
||||
/// <summary>Releases the underlying <see cref="JsonDocument"/>. Idempotent.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_document.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The list of driver-instance specs to spawn, from the artifact's <c>DriverInstances</c> array.
|
||||
/// An artifact with no such array yields an empty list.
|
||||
/// </summary>
|
||||
/// <returns>The parsed driver-instance specs.</returns>
|
||||
public IReadOnlyList<DriverInstanceSpec> ParseDriverInstances()
|
||||
{
|
||||
var root = Root;
|
||||
if (!root.TryGetProperty("DriverInstances", out var arr)
|
||||
|| arr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<DriverInstanceSpec>();
|
||||
}
|
||||
|
||||
// v3: each spec's DriverConfig is the MERGED config the driver factory binds from —
|
||||
// DriverConfig (protocol/channel) + per-Device DeviceConfig (endpoint) + the driver's authored
|
||||
// raw tags (RawTags array, keyed by RawPath) — folded once here via DriverDeviceConfigMerger so
|
||||
// both spawn (TryCreate) and redeploy (ApplyDelta) hand the driver the same shape.
|
||||
var rawTagsByDriver = BuildRawTagsByDriver(root);
|
||||
var deviceRowsByDriver = BuildDeviceRowsByDriver(root);
|
||||
|
||||
var result = new List<DriverInstanceSpec>(arr.GetArrayLength());
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind != JsonValueKind.Object) continue;
|
||||
var spec = TryReadSpec(el, rawTagsByDriver, deviceRowsByDriver);
|
||||
if (spec is not null) result.Add(spec);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Flatten the artifact's raw topology (RawFolders / DriverInstances / Devices / TagGroups /
|
||||
/// Tags) into each driver's authored <see cref="RawTagEntry"/> list, keyed by DriverInstanceId. Each
|
||||
/// tag's RawPath is computed via the shared <see cref="RawPathResolver"/> (the single identity authority),
|
||||
@@ -499,71 +687,67 @@ public static class DeploymentArtifact
|
||||
el.TryGetProperty(property, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
|
||||
|
||||
/// <summary>
|
||||
/// Resolve how a node should scope a deployment artifact to its own ClusterId. Single-cluster
|
||||
/// Resolve how a node should scope this 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).
|
||||
/// ClusterId; a missing row resolves to <see cref="ClusterFilterMode.Suppress"/>.
|
||||
/// Memoized per node id — every consumer of one apply asks about the same node.
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to inspect.</param>
|
||||
/// <param name="nodeId">The node's identity (e.g. a "host:port" string) 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)
|
||||
public ClusterScope ResolveClusterScope(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);
|
||||
if (_scopeByNodeId.TryGetValue(nodeId, out var cached)) return cached;
|
||||
var resolved = ResolveClusterScopeCore(nodeId);
|
||||
_scopeByNodeId[nodeId] = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
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.OrdinalIgnoreCase)) 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)
|
||||
private ClusterScope ResolveClusterScopeCore(string nodeId)
|
||||
{
|
||||
var root = Root;
|
||||
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)
|
||||
{
|
||||
return new ClusterScope(ClusterFilterMode.None, null);
|
||||
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.OrdinalIgnoreCase)) 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);
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// The driver-instance specs a specific node should spawn, scoped to its own ClusterId via
|
||||
/// <see cref="ResolveClusterScope(string)"/>. 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. a "host:port" string) 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)
|
||||
public IReadOnlyList<DriverInstanceSpec> ParseDriverInstances(string nodeId)
|
||||
{
|
||||
var scope = ResolveClusterScope(blob, nodeId);
|
||||
var scope = ResolveClusterScope(nodeId);
|
||||
if (scope.Mode == ClusterFilterMode.Suppress) return Array.Empty<DriverInstanceSpec>();
|
||||
var all = ParseDriverInstances(blob);
|
||||
var all = ParseDriverInstances();
|
||||
return scope.Mode == ClusterFilterMode.ScopeTo
|
||||
? all.Where(s => string.Equals(s.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase)).ToArray()
|
||||
: all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the data-plane authorization inputs a node needs to authenticate OPC UA sessions:
|
||||
/// The data-plane authorization inputs a node needs to authenticate OPC UA sessions:
|
||||
/// the <c>ApiKeys</c> the artifact carries and the <c>NodeAcls</c> granting their principals
|
||||
/// permissions (#520, stage 4).
|
||||
/// </summary>
|
||||
@@ -573,60 +757,48 @@ public static class DeploymentArtifact
|
||||
/// it with no ConfigDb, so without this it denies every UserName token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope"/>); keys are
|
||||
/// not, because <c>ApiKey</c> carries no ClusterId — a key is a fleet-wide credential and it
|
||||
/// Grants are scoped to the node's own cluster (<see cref="ResolveClusterScope(string)"/>); keys
|
||||
/// are not, because <c>ApiKey</c> carries no ClusterId — a key is a fleet-wide credential and it
|
||||
/// is the ACL rows that bind it to a cluster's nodes. A <see cref="ClusterFilterMode.Suppress"/>
|
||||
/// scope — the node's row is absent from a multi-cluster artifact — yields NO grants, matching
|
||||
/// the driver-spawn path's reading that an unlisted node was not deployed to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lenient like every other parser here: an empty or malformed blob yields empty lists. The
|
||||
/// caller must NOT treat that as "no keys are valid" and install it — see issue #485; the
|
||||
/// apply paths only reach this with bytes that already passed their own emptiness guard.
|
||||
/// Lenient like every other parser here: an artifact carrying neither section yields empty
|
||||
/// lists. The caller must NOT treat that as "no keys are valid" and install it — see issue
|
||||
/// #485; the apply paths only reach this with bytes that already passed their own guard.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="blob">The deployment artifact blob to parse.</param>
|
||||
/// <param name="nodeId">The node's identity, used to resolve cluster scope for the grants.</param>
|
||||
/// <returns>The node's cluster (null when single-cluster), its keys, and its cluster's grants.</returns>
|
||||
public static DataPlaneAuthorityProjection ParseDataPlaneAuthority(ReadOnlySpan<byte> blob, string nodeId)
|
||||
public DataPlaneAuthorityProjection ParseDataPlaneAuthority(string nodeId)
|
||||
{
|
||||
if (blob.IsEmpty) return DataPlaneAuthorityProjection.Empty;
|
||||
var scope = ResolveClusterScope(nodeId);
|
||||
var root = Root;
|
||||
|
||||
var scope = ResolveClusterScope(blob, nodeId);
|
||||
|
||||
try
|
||||
var keys = new List<ApiKey>();
|
||||
foreach (var el in EnumerateArray(root, "ApiKeys"))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var keys = new List<ApiKey>();
|
||||
foreach (var el in EnumerateArray(root, "ApiKeys"))
|
||||
{
|
||||
var key = TryReadApiKey(el);
|
||||
if (key is not null) keys.Add(key);
|
||||
}
|
||||
|
||||
var grants = new List<NodeAcl>();
|
||||
foreach (var el in EnumerateArray(root, "NodeAcls"))
|
||||
{
|
||||
var acl = TryReadNodeAcl(el);
|
||||
if (acl is null) continue;
|
||||
if (scope.Mode == ClusterFilterMode.Suppress) continue;
|
||||
if (scope.Mode == ClusterFilterMode.ScopeTo
|
||||
&& !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
grants.Add(acl);
|
||||
}
|
||||
|
||||
return new DataPlaneAuthorityProjection(
|
||||
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
|
||||
var key = TryReadApiKey(el);
|
||||
if (key is not null) keys.Add(key);
|
||||
}
|
||||
catch (JsonException)
|
||||
|
||||
var grants = new List<NodeAcl>();
|
||||
foreach (var el in EnumerateArray(root, "NodeAcls"))
|
||||
{
|
||||
return DataPlaneAuthorityProjection.Empty;
|
||||
var acl = TryReadNodeAcl(el);
|
||||
if (acl is null) continue;
|
||||
if (scope.Mode == ClusterFilterMode.Suppress) continue;
|
||||
if (scope.Mode == ClusterFilterMode.ScopeTo
|
||||
&& !string.Equals(acl.ClusterId, scope.ClusterId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
grants.Add(acl);
|
||||
}
|
||||
|
||||
return new DataPlaneAuthorityProjection(
|
||||
scope.ClusterId, keys, grants, BuildAclScopePaths(root, grants));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -880,25 +1052,19 @@ public static class DeploymentArtifact
|
||||
|
||||
/// <summary>
|
||||
/// Parse the artifact into the projected <see cref="AddressSpaceComposition"/> used by
|
||||
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. Returns an empty composition for empty/
|
||||
/// malformed blobs so callers can treat parse failure as a no-op deploy.
|
||||
/// <c>AddressSpacePlanner</c> + <c>AddressSpaceApplier</c>. An artifact carrying none of the
|
||||
/// projected arrays yields an empty composition, which callers treat as a no-op deploy.
|
||||
///
|
||||
/// The artifact JSON is produced by <c>ConfigComposer.SnapshotAndFlattenAsync</c> in the
|
||||
/// ControlPlane — its Pascal-case property names match the EF entities. We only need a
|
||||
/// subset of fields per entity class to drive the address-space rebuild on driver-role
|
||||
/// nodes.
|
||||
/// </summary>
|
||||
/// <param name="blob">The deployment artifact blob to parse.</param>
|
||||
/// <returns>The projected <see cref="AddressSpaceComposition"/>, or an empty composition for empty/malformed blobs.</returns>
|
||||
public static AddressSpaceComposition ParseComposition(ReadOnlySpan<byte> blob)
|
||||
/// <returns>The projected <see cref="AddressSpaceComposition"/>.</returns>
|
||||
public AddressSpaceComposition ParseComposition()
|
||||
{
|
||||
if (blob.IsEmpty) return Empty();
|
||||
|
||||
try
|
||||
var root = Root;
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
var areas = ReadArray(root, "UnsAreas", ReadAreaProjection);
|
||||
var lines = ReadArray(root, "UnsLines", ReadLineProjection);
|
||||
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
|
||||
@@ -932,14 +1098,10 @@ public static class DeploymentArtifact
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Empty();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Cluster-scoped overload: the address-space composition a node should materialise given
|
||||
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope"/>).
|
||||
/// its NodeId. Filters every projection to the node's own ClusterId (see <see cref="ResolveClusterScope(string)"/>).
|
||||
/// Equipment attribution is dual-path: driver-bound equipment (non-null DriverInstanceId) is kept when
|
||||
/// its driver is in-cluster; driver-less equipment (null DriverInstanceId) is kept when its UNS line's
|
||||
/// area is in-cluster.
|
||||
@@ -948,19 +1110,17 @@ public static class DeploymentArtifact
|
||||
/// that violates the same-cluster invariant and would orphan the equipment folder. This is
|
||||
/// detection only (observability); the equipment is still returned, since the upstream draft validator
|
||||
/// is the authority that should prevent the binding in the first place.</summary>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="nodeId">This node's identity in "host:port" form.</param>
|
||||
/// <param name="onInconsistency">Optional diagnostic callback for cross-cluster orphan bindings; null disables the check.</param>
|
||||
/// <returns>The filtered composition per the node's scoping decision.</returns>
|
||||
public static AddressSpaceComposition ParseComposition(
|
||||
ReadOnlySpan<byte> blob, string nodeId, Action<string>? onInconsistency = null)
|
||||
public AddressSpaceComposition ParseComposition(string nodeId, Action<string>? onInconsistency = null)
|
||||
{
|
||||
var scope = ResolveClusterScope(blob, nodeId);
|
||||
if (scope.Mode == ClusterFilterMode.None) return ParseComposition(blob);
|
||||
var scope = ResolveClusterScope(nodeId);
|
||||
if (scope.Mode == ClusterFilterMode.None) return ParseComposition();
|
||||
if (scope.Mode == ClusterFilterMode.Suppress) return Empty();
|
||||
|
||||
var full = ParseComposition(blob);
|
||||
var sets = BuildClusterSets(blob, scope.ClusterId!);
|
||||
var full = ParseComposition();
|
||||
var sets = BuildClusterSets(Root, scope.ClusterId!);
|
||||
|
||||
var keptLines = full.UnsLines.Where(l => sets.AreaIds.Contains(l.UnsAreaId)).ToArray();
|
||||
var keptEquipment = full.EquipmentNodes.Where(e => sets.EquipmentIds.Contains(e.EquipmentId)).ToArray();
|
||||
@@ -1012,18 +1172,16 @@ public static class DeploymentArtifact
|
||||
/// that directly carry the ClusterId, plus in-cluster EquipmentIds — driver-bound equipment attributed
|
||||
/// by its driver's cluster, and driver-less equipment (null DriverInstanceId) attributed by its UNS
|
||||
/// line's area cluster.</summary>
|
||||
/// <param name="blob">The deployment artifact blob.</param>
|
||||
/// <param name="root">The artifact root element — the SAME document every other consumer reads, rather
|
||||
/// than a second parse of the same bytes (this used to re-parse the blob on every scoped compose).</param>
|
||||
/// <param name="clusterId">The node's ClusterId to scope to.</param>
|
||||
/// <returns>The resolved in-cluster id sets (empty on parse failure => empty composition).</returns>
|
||||
private static ClusterSets BuildClusterSets(ReadOnlySpan<byte> blob, string clusterId)
|
||||
/// <returns>The resolved in-cluster id sets.</returns>
|
||||
private static ClusterSets BuildClusterSets(JsonElement root, string clusterId)
|
||||
{
|
||||
var driverIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var areaIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
var equipmentIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(blob.ToArray());
|
||||
var root = doc.RootElement;
|
||||
CollectIdsWhereCluster(root, "DriverInstances", "DriverInstanceId", clusterId, driverIds);
|
||||
CollectIdsWhereCluster(root, "UnsAreas", "UnsAreaId", clusterId, areaIds);
|
||||
// Map each UnsLine to its owning UnsArea so driver-less equipment can be attributed via
|
||||
@@ -1055,7 +1213,6 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException) { /* empty sets => nothing matches => empty composition */ }
|
||||
return new ClusterSets(driverIds, areaIds, equipmentIds);
|
||||
}
|
||||
|
||||
@@ -1080,7 +1237,9 @@ public static class DeploymentArtifact
|
||||
}
|
||||
}
|
||||
|
||||
private static AddressSpaceComposition Empty() => new(
|
||||
/// <summary>The composition an absent / empty / unparseable artifact projects to: nothing at all.</summary>
|
||||
/// <returns>An empty composition.</returns>
|
||||
internal static AddressSpaceComposition Empty() => new(
|
||||
Array.Empty<UnsAreaProjection>(),
|
||||
Array.Empty<UnsLineProjection>(),
|
||||
Array.Empty<EquipmentNode>(),
|
||||
|
||||
Reference in New Issue
Block a user