diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs new file mode 100644 index 00000000..2aa6eab2 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Grpc/DeploymentArtifactService.cs @@ -0,0 +1,92 @@ +using Google.Protobuf; +using Grpc.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +// The generated service container is itself named DeploymentArtifactService; alias its nested server +// base so this impl can keep the natural name without colliding with the generated type. +using GeneratedServiceBase = + ZB.MOM.WW.OtOpcUa.Commons.Protos.DeploymentArtifact.V1.DeploymentArtifactService.DeploymentArtifactServiceBase; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Grpc; + +/// +/// Central-side artifact serve (per-cluster mesh Phase 3). Streams a sealed deployment's +/// ArtifactBlob to a driver node fetching in FetchAndCache mode. Registered only on +/// admin-role nodes (which hold the central SQL connection) and only when +/// ConfigServe:GrpcListenPort > 0. +/// +/// +/// +/// NotFound is deliberately indistinguishable across three cases — unknown id, a +/// deployment that is not sealed, and an empty blob. The first is existence-hiding; the last +/// is the #485 rule (a zero-length artifact is "no answer," never "a configuration with no +/// drivers"). Streaming empty bytes as if valid is exactly what that defect class forbids. +/// +/// +public sealed class DeploymentArtifactGrpcService : GeneratedServiceBase +{ + /// Chunk size, matching LocalDbDeploymentArtifactCache.ChunkSize (128 KiB). + private const int ChunkSize = 128 * 1024; + + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _log; + + /// Initializes a new instance of the class. + /// Factory for the central config database holding the artifact bytes. + /// Logger. + public DeploymentArtifactGrpcService( + IDbContextFactory dbFactory, + ILogger log) + { + _dbFactory = dbFactory; + _log = log; + } + + /// + public override async Task Fetch( + FetchRequest request, + IServerStreamWriter responseStream, + ServerCallContext context) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(responseStream); + ArgumentNullException.ThrowIfNull(context); + + if (!Guid.TryParse(request.DeploymentId, out var id)) + throw new RpcException(new Status(StatusCode.NotFound, "unknown deployment")); + + await using var db = await _dbFactory.CreateDbContextAsync(context.CancellationToken); + var row = await db.Deployments + .AsNoTracking() + .Where(d => d.DeploymentId == id) + .Select(d => new { d.Status, d.ArtifactBlob }) + .FirstOrDefaultAsync(context.CancellationToken); + + // Existence-hiding + #485: unknown / not-sealed / empty collapse to one NotFound. + if (row is null || row.Status != DeploymentStatus.Sealed || row.ArtifactBlob.Length == 0) + { + _log.LogDebug( + "Artifact fetch for {DeploymentId} -> NotFound (found={Found}, sealed={Sealed}, bytes={Bytes})", + request.DeploymentId, row is not null, + row is not null && row.Status == DeploymentStatus.Sealed, + row?.ArtifactBlob.Length ?? 0); + throw new RpcException(new Status(StatusCode.NotFound, "unknown deployment")); + } + + for (var offset = 0; offset < row.ArtifactBlob.Length; offset += ChunkSize) + { + var len = Math.Min(ChunkSize, row.ArtifactBlob.Length - offset); + await responseStream.WriteAsync( + new ArtifactChunk { Data = ByteString.CopyFrom(row.ArtifactBlob, offset, len) }, + context.CancellationToken); + } + + _log.LogInformation( + "Served artifact for {DeploymentId} ({Bytes} bytes, {Chunks} chunks)", + request.DeploymentId, row.ArtifactBlob.Length, + (row.ArtifactBlob.Length + ChunkSize - 1) / ChunkSize); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs new file mode 100644 index 00000000..f31d9843 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Grpc/DeploymentArtifactServiceTests.cs @@ -0,0 +1,159 @@ +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; + +/// +/// 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). +/// +public class DeploymentArtifactServiceTests +{ + private static IDbContextFactory Factory(string name) => new NamedFactory(name); + + private static DeploymentArtifactGrpcService Service(string dbName) => + new(Factory(dbName), NullLogger.Instance); + + private static Guid Seed(string dbName, DeploymentStatus status, byte[] blob) + { + var id = Guid.NewGuid(); + using var db = new OtOpcUaConfigDbContext( + new DbContextOptionsBuilder().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(() => 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_throws_NotFound() + { + var dbName = $"artsvc-{Guid.NewGuid():N}"; + var id = Seed(dbName, DeploymentStatus.AwaitingApplyAcks, [1, 2, 3]); + + var ex = await Should.ThrowAsync(() => Service(dbName).Fetch( + new FetchRequest { DeploymentId = id.ToString() }, + new CollectingStreamWriter(), new FakeServerCallContext())); + + ex.StatusCode.ShouldBe(StatusCode.NotFound); + } + + [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(() => 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(() => 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 + { + public List 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 + { + public OtOpcUaConfigDbContext CreateDbContext() => + new(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + + public Task CreateDbContextAsync(CancellationToken ct = default) => + Task.FromResult(CreateDbContext()); + } + + /// Minimal — the service reads only the cancellation token. + 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>()); + + protected override ContextPropagationToken CreatePropagationTokenCore(ContextPropagationOptions? options) => + throw new NotSupportedException(); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) => Task.CompletedTask; + } +}