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;
///
/// 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, FetchAndCache).
///
///
///
/// One invariant. Return the first endpoint's bytes that both reassemble AND verify
/// (SHA-256(bytes) lowercase-hex == the expected revision hash); otherwise
/// . Every per-endpoint problem — an
/// (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.
///
///
/// Why , not throw. The caller treats
/// 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.
///
///
public sealed class GrpcDeploymentArtifactFetcher : IDeploymentArtifactFetcher, IDisposable
{
private readonly ConfigSourceOptions _options;
private readonly ILogger _log;
private readonly Func _clientFactory;
private readonly ConcurrentDictionary _channels = new(StringComparer.Ordinal);
/// Initializes a new instance of the class.
/// Config-source options; supplies the endpoints, shared key, and deadline.
/// Logger.
///
/// Seam for tests: builds the generated client for an endpoint. Defaults to a real h2c
/// (one cached per endpoint).
///
public GrpcDeploymentArtifactFetcher(
IOptions options,
ILogger log,
Func? clientFactory = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(log);
_options = options.Value;
_log = log;
_clientFactory = clientFactory ?? DefaultClientFactory;
}
///
public async Task 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 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);
}
///
public void Dispose()
{
foreach (var channel in _channels.Values)
channel.Dispose();
_channels.Clear();
}
}