feat(mesh): central DeploymentArtifactService — stream the sealed artifact, NotFound-hide the rest

Streams a sealed deployment's ArtifactBlob to a fetching driver node in
128-KiB chunks (matching the LocalDb cache chunk size). Unknown id, a
non-sealed deployment, and a zero-length blob all collapse to one
indistinguishable NotFound — existence-hiding plus the #485 serve-side guard
(never stream empty bytes as a valid empty config). The impl is named
DeploymentArtifactGrpcService to avoid colliding with the generated
DeploymentArtifactService container type.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-22 19:08:20 -04:00
parent 62e8f54f71
commit 7d7de482b7
2 changed files with 251 additions and 0 deletions
@@ -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;
/// <summary>
/// Central-side artifact serve (per-cluster mesh Phase 3). Streams a sealed deployment's
/// <c>ArtifactBlob</c> to a driver node fetching in <c>FetchAndCache</c> mode. Registered only on
/// admin-role nodes (which hold the central SQL connection) and only when
/// <c>ConfigServe:GrpcListenPort &gt; 0</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>NotFound is deliberately indistinguishable across three cases</b> — 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.
/// </para>
/// </remarks>
public sealed class DeploymentArtifactGrpcService : GeneratedServiceBase
{
/// <summary>Chunk size, matching <c>LocalDbDeploymentArtifactCache.ChunkSize</c> (128 KiB).</summary>
private const int ChunkSize = 128 * 1024;
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
private readonly ILogger<DeploymentArtifactGrpcService> _log;
/// <summary>Initializes a new instance of the <see cref="DeploymentArtifactGrpcService"/> class.</summary>
/// <param name="dbFactory">Factory for the central config database holding the artifact bytes.</param>
/// <param name="log">Logger.</param>
public DeploymentArtifactGrpcService(
IDbContextFactory<OtOpcUaConfigDbContext> dbFactory,
ILogger<DeploymentArtifactGrpcService> log)
{
_dbFactory = dbFactory;
_log = log;
}
/// <inheritdoc />
public override async Task Fetch(
FetchRequest request,
IServerStreamWriter<ArtifactChunk> 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);
}
}