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
163 lines
6.3 KiB
C#
163 lines
6.3 KiB
C#
using System.Security.Cryptography;
|
|
using Google.Protobuf;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.DeploymentCache;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.DeploymentCache;
|
|
|
|
/// <summary>
|
|
/// Per-cluster mesh Phase 3 (Task 4) — the node-side gRPC artifact fetcher's logic: reassembly,
|
|
/// SHA-256 verification, endpoint failover, and the null-on-any-failure (#485) contract. The real
|
|
/// gRPC stream across a real Kestrel h2c listener is the Task 8 boundary test; here the client is
|
|
/// a fake streaming canned chunks, so only the fetcher's own logic is under test.
|
|
/// </summary>
|
|
public sealed class GrpcDeploymentArtifactFetcherTests
|
|
{
|
|
private const string DeploymentId = "11111111-1111-1111-1111-111111111111";
|
|
|
|
private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes));
|
|
|
|
private static GrpcDeploymentArtifactFetcher Fetcher(
|
|
string[] endpoints, Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient> factory)
|
|
=> new(
|
|
Options.Create(new ConfigSourceOptions
|
|
{
|
|
Mode = ConfigSourceOptions.ModeFetchAndCache,
|
|
CentralFetchEndpoints = endpoints,
|
|
ApiKey = "k",
|
|
FetchTimeoutSeconds = 30,
|
|
}),
|
|
NullLogger<GrpcDeploymentArtifactFetcher>.Instance,
|
|
factory);
|
|
|
|
[Fact]
|
|
public async Task Chunks_reassembling_to_the_expected_hash_return_the_exact_bytes()
|
|
{
|
|
var blob = new byte[300 * 1024];
|
|
for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
|
|
var factory = SingleClient(StreamOf(blob, chunkSize: 128 * 1024));
|
|
|
|
var result = await Fetcher(["http://central-1:4055"], factory)
|
|
.FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);
|
|
|
|
result.ShouldBe(blob);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_hash_mismatch_returns_null()
|
|
{
|
|
var blob = new byte[] { 1, 2, 3, 4 };
|
|
var factory = SingleClient(StreamOf(blob, chunkSize: 2));
|
|
|
|
var result = await Fetcher(["http://central-1:4055"], factory)
|
|
.FetchAsync(DeploymentId, Sha256Hex([9, 9, 9, 9]), CancellationToken.None);
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_empty_stream_returns_null()
|
|
{
|
|
var factory = SingleClient(StreamOf([], chunkSize: 128 * 1024));
|
|
|
|
var result = await Fetcher(["http://central-1:4055"], factory)
|
|
.FetchAsync(DeploymentId, Sha256Hex([]), CancellationToken.None);
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task When_the_first_endpoint_is_unavailable_it_fails_over_to_the_second()
|
|
{
|
|
var blob = new byte[] { 10, 20, 30, 40, 50 };
|
|
var consulted = new List<string>();
|
|
var factory = (string endpoint) =>
|
|
{
|
|
consulted.Add(endpoint);
|
|
return endpoint.Contains("central-1")
|
|
? (DeploymentArtifactService.DeploymentArtifactServiceClient)
|
|
new ThrowingClient(new RpcException(new Status(StatusCode.Unavailable, "down")))
|
|
: new FakeClient(StreamOf(blob, chunkSize: 2));
|
|
};
|
|
|
|
var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
|
|
.FetchAsync(DeploymentId, Sha256Hex(blob), CancellationToken.None);
|
|
|
|
result.ShouldBe(blob);
|
|
// The failover must have actually consulted the second endpoint.
|
|
consulted.ShouldBe(["http://central-1:4055", "http://central-2:4055"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task When_every_endpoint_throws_it_returns_null()
|
|
{
|
|
var factory = (string _) =>
|
|
(DeploymentArtifactService.DeploymentArtifactServiceClient)
|
|
new ThrowingClient(new RpcException(new Status(StatusCode.NotFound, "unknown deployment")));
|
|
|
|
var result = await Fetcher(["http://central-1:4055", "http://central-2:4055"], factory)
|
|
.FetchAsync(DeploymentId, Sha256Hex([1]), CancellationToken.None);
|
|
|
|
result.ShouldBeNull();
|
|
}
|
|
|
|
// ---- fakes --------------------------------------------------------------------------------
|
|
|
|
private static Func<string, DeploymentArtifactService.DeploymentArtifactServiceClient> SingleClient(
|
|
IReadOnlyList<ArtifactChunk> chunks)
|
|
=> _ => new FakeClient(chunks);
|
|
|
|
private static IReadOnlyList<ArtifactChunk> StreamOf(byte[] blob, int chunkSize)
|
|
{
|
|
var chunks = new List<ArtifactChunk>();
|
|
for (var offset = 0; offset < blob.Length; offset += chunkSize)
|
|
{
|
|
var len = Math.Min(chunkSize, blob.Length - offset);
|
|
chunks.Add(new ArtifactChunk { Data = ByteString.CopyFrom(blob, offset, len) });
|
|
}
|
|
|
|
return chunks;
|
|
}
|
|
|
|
/// <summary>A generated client double whose <c>Fetch</c> streams canned chunks.</summary>
|
|
private sealed class FakeClient(IReadOnlyList<ArtifactChunk> chunks)
|
|
: DeploymentArtifactService.DeploymentArtifactServiceClient
|
|
{
|
|
public override AsyncServerStreamingCall<ArtifactChunk> Fetch(FetchRequest request, CallOptions options)
|
|
=> new(
|
|
new ListStreamReader(chunks),
|
|
Task.FromResult(new Metadata()),
|
|
() => Status.DefaultSuccess,
|
|
() => [],
|
|
() => { });
|
|
}
|
|
|
|
/// <summary>A generated client double whose <c>Fetch</c> throws (endpoint down / NotFound).</summary>
|
|
private sealed class ThrowingClient(RpcException toThrow)
|
|
: DeploymentArtifactService.DeploymentArtifactServiceClient
|
|
{
|
|
public override AsyncServerStreamingCall<ArtifactChunk> Fetch(FetchRequest request, CallOptions options)
|
|
=> throw toThrow;
|
|
}
|
|
|
|
private sealed class ListStreamReader(IReadOnlyList<ArtifactChunk> chunks) : IAsyncStreamReader<ArtifactChunk>
|
|
{
|
|
private int _index = -1;
|
|
|
|
public ArtifactChunk Current => chunks[_index];
|
|
|
|
public Task<bool> MoveNext(CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
_index++;
|
|
return Task.FromResult(_index < chunks.Count);
|
|
}
|
|
}
|
|
}
|