feat: complete gRPC streaming channel — site host, docker config, docs, integration tests

Switch site host to WebApplicationBuilder with Kestrel HTTP/2 gRPC server,
add GrpcPort/keepalive config, wire SiteStreamManager as ISiteStreamSubscriber,
expose gRPC ports in docker-compose, add site seed script, update all 10
requirement docs + CLAUDE.md + README.md for the new dual-transport architecture.
This commit is contained in:
Joseph Doherty
2026-03-21 12:38:33 -04:00
parent 3fe3c4161b
commit 416a03b782
34 changed files with 728 additions and 156 deletions

View File

@@ -1,4 +1,6 @@
using System.Reflection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -65,70 +67,74 @@ public class HostStartupTests : IDisposable
[Fact]
public void SiteRole_StartsWithoutError()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
});
var host = builder.Build();
_disposables.Add(host);
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
_disposables.Add(app);
// Build succeeds = DI container is valid and all services resolve
Assert.NotNull(host);
Assert.NotNull(host.Services);
Assert.NotNull(app);
Assert.NotNull(app.Services);
}
[Fact]
public void SiteRole_DoesNotConfigureKestrel()
public void SiteRole_ConfiguresKestrelForGrpc()
{
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
builder.ConfigureAppConfiguration(config =>
var builder = WebApplication.CreateBuilder();
builder.Configuration.Sources.Clear();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
config.Sources.Clear();
config.AddInMemoryCollection(new Dictionary<string, string?>
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
});
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(0, listenOptions =>
{
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2;
});
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
});
var host = builder.Build();
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Verify no Kestrel server or web host is registered.
// Host.CreateDefaultBuilder does not add Kestrel, so there should be no IServer.
// Remove AkkaHostedService from running
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
var app = builder.Build();
// Verify Kestrel IS configured (site now hosts gRPC via WebApplicationBuilder)
var serverType = Type.GetType(
"Microsoft.AspNetCore.Hosting.Server.IServer, Microsoft.AspNetCore.Hosting.Server.Abstractions");
if (serverType != null)
{
var server = host.Services.GetService(serverType);
Assert.Null(server);
var server = app.Services.GetService(serverType);
Assert.NotNull(server);
}
// Additionally verify no HTTP URLs are configured
var config = host.Services.GetRequiredService<IConfiguration>();
var urls = config["urls"] ?? config["ASPNETCORE_URLS"];
Assert.Null(urls);
host.Dispose();
(app as IDisposable)?.Dispose();
}
[Fact]