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(); } }