d9de4e3019
The Phase 3 live gate deadlocked every FetchAndCache deploy: DeploymentArtifactGrpcService gated on Status==Sealed, but central seals a deployment only AFTER every node acks Applied, and a FetchAndCache node cannot ack until it has fetched the artifact — seal-needs-ack -> ack-needs-fetch -> fetch-needs-sealed. The node's fetch reached central and passed the shared-key interceptor, then got a clean NotFound; the #485 apply-failure path correctly kept last-known-good, but the deploy could never seal. Direct mode reads the same ArtifactBlob from SQL while the row is still AwaitingApplyAcks, so the serve path must too. Drop the Sealed gate: serve any deployment whose blob is non-empty (unknown id / empty blob still collapse to NotFound — existence-hiding + #485). Access is already gated by the interceptor, so there is no reason to hide a non-sealed deployment a node is legitimately applying. The Task 2 'non-sealed -> NotFound' test flips to 'non-sealed with a blob is served'. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
165 lines
6.7 KiB
C#
165 lines
6.7 KiB
C#
using Google.Protobuf;
|
|
using Grpc.Core;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// The serve side of Phase 3: streams a sealed deployment's bytes, and collapses unknown /
|
|
/// not-sealed / empty into one indistinguishable NotFound (existence-hiding + #485).
|
|
/// </summary>
|
|
public class DeploymentArtifactServiceTests
|
|
{
|
|
private static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
|
|
|
|
private static DeploymentArtifactGrpcService Service(string dbName) =>
|
|
new(Factory(dbName), NullLogger<DeploymentArtifactGrpcService>.Instance);
|
|
|
|
private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob)
|
|
{
|
|
var id = Guid.NewGuid();
|
|
using var db = new OtOpcUaConfigDbContext(
|
|
new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(dbName).Options);
|
|
db.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id,
|
|
RevisionHash = new string('a', 64),
|
|
Status = status,
|
|
CreatedBy = "test",
|
|
ArtifactBlob = blob,
|
|
});
|
|
db.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_sealed_non_empty_blob_streams_back_byte_equal_and_chunk_bounded()
|
|
{
|
|
var dbName = $"artsvc-{Guid.NewGuid():N}";
|
|
// ~300 KB > 2 * 128 KiB -> exactly 3 chunks.
|
|
var blob = new byte[300 * 1024];
|
|
for (var i = 0; i < blob.Length; i++) blob[i] = (byte)(i % 251);
|
|
var id = Seed(dbName, DeploymentStatus.Sealed, blob);
|
|
|
|
var writer = new CollectingStreamWriter();
|
|
await Service(dbName).Fetch(
|
|
new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext());
|
|
|
|
writer.Chunks.Count.ShouldBe(3);
|
|
writer.Chunks[0].Data.Length.ShouldBe(128 * 1024);
|
|
writer.Chunks[1].Data.Length.ShouldBe(128 * 1024);
|
|
writer.Reassembled().ShouldBe(blob);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_unknown_id_throws_NotFound()
|
|
{
|
|
var dbName = $"artsvc-{Guid.NewGuid():N}";
|
|
Seed(dbName, DeploymentStatus.Sealed, [1, 2, 3]);
|
|
|
|
var ex = await Should.ThrowAsync<RpcException>(() => Service(dbName).Fetch(
|
|
new FetchRequest { DeploymentId = Guid.NewGuid().ToString() },
|
|
new CollectingStreamWriter(), new FakeServerCallContext()));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_sealed_deployment_with_a_blob_is_served()
|
|
{
|
|
// A node fetches DURING apply, before the deployment is sealed (seal-needs-ack →
|
|
// ack-needs-fetch). Gating on Sealed would deadlock every FetchAndCache deploy. So a
|
|
// non-sealed deployment that has a blob must stream — matching Direct mode reading the same
|
|
// AwaitingApplyAcks row from SQL. (Regression for the Phase 3 live-gate deadlock.)
|
|
var dbName = $"artsvc-{Guid.NewGuid():N}";
|
|
var blob = new byte[] { 1, 2, 3 };
|
|
var id = Seed(dbName, DeploymentStatus.AwaitingApplyAcks, blob);
|
|
|
|
var writer = new CollectingStreamWriter();
|
|
await Service(dbName).Fetch(
|
|
new FetchRequest { DeploymentId = id.ToString() }, writer, new FakeServerCallContext());
|
|
|
|
writer.Reassembled().ShouldBe(blob);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_zero_length_blob_throws_NotFound()
|
|
{
|
|
// The serve-side #485 guard: never stream empty bytes as if they were a valid empty config.
|
|
var dbName = $"artsvc-{Guid.NewGuid():N}";
|
|
var id = Seed(dbName, DeploymentStatus.Sealed, []);
|
|
|
|
var ex = await Should.ThrowAsync<RpcException>(() => Service(dbName).Fetch(
|
|
new FetchRequest { DeploymentId = id.ToString() },
|
|
new CollectingStreamWriter(), new FakeServerCallContext()));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_non_guid_id_throws_NotFound()
|
|
{
|
|
var ex = await Should.ThrowAsync<RpcException>(() => Service($"artsvc-{Guid.NewGuid():N}").Fetch(
|
|
new FetchRequest { DeploymentId = "not-a-guid" },
|
|
new CollectingStreamWriter(), new FakeServerCallContext()));
|
|
|
|
ex.StatusCode.ShouldBe(StatusCode.NotFound);
|
|
}
|
|
|
|
private sealed class CollectingStreamWriter : IServerStreamWriter<ArtifactChunk>
|
|
{
|
|
public List<ArtifactChunk> Chunks { get; } = [];
|
|
public WriteOptions? WriteOptions { get; set; }
|
|
|
|
public Task WriteAsync(ArtifactChunk message)
|
|
{
|
|
Chunks.Add(message);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public byte[] Reassembled()
|
|
{
|
|
using var ms = new MemoryStream();
|
|
foreach (var c in Chunks) c.Data.WriteTo(ms);
|
|
return ms.ToArray();
|
|
}
|
|
}
|
|
|
|
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
|
|
{
|
|
public OtOpcUaConfigDbContext CreateDbContext() =>
|
|
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
|
|
|
|
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken ct = default) =>
|
|
Task.FromResult(CreateDbContext());
|
|
}
|
|
|
|
/// <summary>Minimal <see cref="ServerCallContext"/> — the service reads only the cancellation token.</summary>
|
|
private sealed class FakeServerCallContext : ServerCallContext
|
|
{
|
|
protected override string MethodCore => "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
|
|
protected override string HostCore => "localhost";
|
|
protected override string PeerCore => "ipv4:127.0.0.1:0";
|
|
protected override DateTime DeadlineCore => DateTime.MaxValue;
|
|
protected override Metadata RequestHeadersCore => [];
|
|
protected override CancellationToken CancellationTokenCore => CancellationToken.None;
|
|
protected override Metadata ResponseTrailersCore { get; } = [];
|
|
protected override Status StatusCore { get; set; }
|
|
protected override WriteOptions? WriteOptionsCore { get; set; }
|
|
protected override AuthContext AuthContextCore => new(null, new Dictionary<string, List<AuthProperty>>());
|
|
|
|
protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) =>
|
|
throw new NotSupportedException();
|
|
|
|
protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask;
|
|
}
|
|
}
|