7a8fb5f129
Phase 3 Task 4. GrpcDeploymentArtifactFetcher streams the artifact from the first configured central endpoint that answers, reassembles the chunks, and verifies SHA-256(bytes) lowercase-hex == the dispatched RevisionHash (byte-identical to ConfigComposer's Convert.ToHexStringLower(SHA256.HashData(blob))). Every per-endpoint problem — RpcException, zero-length stream, or hash mismatch — is logged and the next endpoint tried; if none yield verified bytes it returns null (apply failure, keep last-known-good — the #485 contract), never throwing into the actor loop. A Func<endpoint, client> seam keeps the gRPC boundary out of the unit tests (that is Task 8's job); the real path caches one h2c GrpcChannel per endpoint. Files live under the .DeploymentCache namespace (folder Deployment/) to avoid shadowing the Configuration.Entities.Deployment type in the test assembly — same convention as LocalDbDeploymentArtifactCache. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
154 lines
7.0 KiB
C#
154 lines
7.0 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Security.Cryptography;
|
|
using Grpc.Core;
|
|
using Grpc.Net.Client;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
|
|
/// <summary>
|
|
/// Streams the deployed-configuration artifact from central over gRPC and verifies it against the
|
|
/// revision hash the dispatch already carries (per-cluster mesh Phase 3, <c>FetchAndCache</c>).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>One invariant.</b> Return the first endpoint's bytes that both reassemble AND verify
|
|
/// (<c>SHA-256(bytes)</c> lowercase-hex == the expected revision hash); otherwise
|
|
/// <see langword="null"/>. Every per-endpoint problem — an <see cref="RpcException"/>
|
|
/// (Unavailable / NotFound / deadline), a zero-length stream, or a hash mismatch — is treated
|
|
/// uniformly as "this endpoint did not supply valid bytes," logged, and the next endpoint is
|
|
/// tried. A hash mismatch is tried-next rather than fatal because a partitioned central could
|
|
/// legitimately serve stale bytes from one node and current bytes from another; when every
|
|
/// central shares one SQL store (the common case) the retry is merely cheap and harmless.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Why <see langword="null"/>, not throw.</b> The caller treats <see langword="null"/>
|
|
/// identically to today's "reconcile returned null": an apply failure that keeps
|
|
/// last-known-good and does not advance the revision. A typed "no bytes" keeps the #485
|
|
/// contract explicit and cannot escape as an unhandled actor message.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class GrpcDeploymentArtifactFetcher : IDeploymentArtifactFetcher, IDisposable
|
|
{
|
|
private readonly ConfigSourceOptions _options;
|
|
private readonly ILogger<GrpcDeploymentArtifactFetcher> _log;
|
|
private readonly Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient> _clientFactory;
|
|
private readonly ConcurrentDictionary<string, GrpcChannel> _channels = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>Initializes a new instance of the <see cref="GrpcDeploymentArtifactFetcher"/> class.</summary>
|
|
/// <param name="options">Config-source options; supplies the endpoints, shared key, and deadline.</param>
|
|
/// <param name="log">Logger.</param>
|
|
/// <param name="clientFactory">
|
|
/// Seam for tests: builds the generated client for an endpoint. Defaults to a real h2c
|
|
/// <see cref="GrpcChannel"/> (one cached per endpoint).
|
|
/// </param>
|
|
public GrpcDeploymentArtifactFetcher(
|
|
IOptions<ConfigSourceOptions> options,
|
|
ILogger<GrpcDeploymentArtifactFetcher> log,
|
|
Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient>? clientFactory = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
ArgumentNullException.ThrowIfNull(log);
|
|
|
|
_options = options.Value;
|
|
_log = log;
|
|
_clientFactory = clientFactory ?? DefaultClientFactory;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<byte[]?> FetchAsync(string deploymentId, string expectedRevisionHash, CancellationToken ct)
|
|
{
|
|
ArgumentException.ThrowIfNullOrEmpty(deploymentId);
|
|
ArgumentException.ThrowIfNullOrEmpty(expectedRevisionHash);
|
|
|
|
var endpoints = _options.CentralFetchEndpoints;
|
|
if (endpoints.Length == 0)
|
|
{
|
|
_log.LogWarning(
|
|
"Artifact fetch for {DeploymentId} skipped: no ConfigSource:CentralFetchEndpoints configured.",
|
|
deploymentId);
|
|
return null;
|
|
}
|
|
|
|
foreach (var endpoint in endpoints)
|
|
{
|
|
try
|
|
{
|
|
var bytes = await FetchFromEndpointAsync(endpoint, deploymentId, ct);
|
|
if (bytes.Length == 0)
|
|
{
|
|
_log.LogWarning(
|
|
"Artifact fetch for {DeploymentId} from {Endpoint} returned zero bytes; trying next endpoint.",
|
|
deploymentId, endpoint);
|
|
continue;
|
|
}
|
|
|
|
var actualHash = Convert.ToHexStringLower(SHA256.HashData(bytes));
|
|
if (!string.Equals(actualHash, expectedRevisionHash, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_log.LogWarning(
|
|
"Artifact fetch for {DeploymentId} from {Endpoint} failed SHA-256 verification " +
|
|
"(expected {Expected}, got {Actual}); trying next endpoint.",
|
|
deploymentId, endpoint, expectedRevisionHash, actualHash);
|
|
continue;
|
|
}
|
|
|
|
_log.LogInformation(
|
|
"Fetched + verified artifact for {DeploymentId} from {Endpoint} ({Bytes} bytes).",
|
|
deploymentId, endpoint, bytes.Length);
|
|
return bytes;
|
|
}
|
|
catch (RpcException ex)
|
|
{
|
|
_log.LogWarning(
|
|
"Artifact fetch for {DeploymentId} from {Endpoint} failed ({Status}); trying next endpoint.",
|
|
deploymentId, endpoint, ex.StatusCode);
|
|
}
|
|
}
|
|
|
|
_log.LogWarning(
|
|
"Artifact fetch for {DeploymentId} failed on ALL {Count} endpoint(s); returning no bytes " +
|
|
"(apply failure — last-known-good is kept).",
|
|
deploymentId, endpoints.Length);
|
|
return null;
|
|
}
|
|
|
|
private async Task<byte[]> FetchFromEndpointAsync(string endpoint, string deploymentId, CancellationToken ct)
|
|
{
|
|
var client = _clientFactory(endpoint);
|
|
var headers = new Metadata { { "authorization", $"Bearer {_options.ApiKey}" } };
|
|
|
|
using var deadlineCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
if (_options.FetchTimeoutSeconds > 0)
|
|
deadlineCts.CancelAfter(TimeSpan.FromSeconds(_options.FetchTimeoutSeconds));
|
|
|
|
using var call = client.Fetch(
|
|
new FetchRequest { DeploymentId = deploymentId }, headers, cancellationToken: deadlineCts.Token);
|
|
|
|
using var buffer = new MemoryStream();
|
|
await foreach (var chunk in call.ResponseStream.ReadAllAsync(deadlineCts.Token))
|
|
chunk.Data.WriteTo(buffer);
|
|
|
|
return buffer.ToArray();
|
|
}
|
|
|
|
private DeploymentArtifactService.DeploymentArtifactServiceClient DefaultClientFactory(string endpoint)
|
|
{
|
|
// h2c: GrpcChannel over an http:// address negotiates prior-knowledge HTTP/2. One channel per
|
|
// endpoint, cached and reused (creating a channel per call would leak sockets).
|
|
var channel = _channels.GetOrAdd(endpoint, ep => GrpcChannel.ForAddress(ep));
|
|
return new DeploymentArtifactService.DeploymentArtifactServiceClient(channel);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
foreach (var channel in _channels.Values)
|
|
channel.Dispose();
|
|
_channels.Clear();
|
|
}
|
|
}
|