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,3 +1,4 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
@@ -21,10 +22,12 @@ using ScadaLink.ManagementService;
using ScadaLink.NotificationService;
using ScadaLink.Security;
using ScadaLink.SiteEventLogging;
using ScadaLink.Communication.Grpc;
using ScadaLink.SiteRuntime;
using ScadaLink.SiteRuntime.Persistence;
using ScadaLink.SiteRuntime.Repositories;
using ScadaLink.SiteRuntime.Scripts;
using ScadaLink.SiteRuntime.Streaming;
using ScadaLink.StoreAndForward;
using ScadaLink.TemplateEngine;
using ScadaLink.TemplateEngine.Flattening;
@@ -274,45 +277,45 @@ public class CentralCompositionRootTests : IDisposable
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
/// matches the real Program.cs registration.
/// matches the real Program.cs registration (WebApplicationBuilder + gRPC).
/// </summary>
public class SiteCompositionRootTests : IDisposable
{
private readonly IHost _host;
private readonly WebApplication _host;
private readonly string _tempDbPath;
public SiteCompositionRootTests()
{
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_test_{Guid.NewGuid()}.db");
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:Database:SiteDbPath"] = _tempDbPath,
});
["ScadaLink:Node:Role"] = "Site",
["ScadaLink:Node:NodeHostname"] = "test-site",
["ScadaLink:Node:SiteId"] = "TestSite",
["ScadaLink:Node:RemotingPort"] = "0",
["ScadaLink:Node:GrpcPort"] = "0",
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
});
builder.ConfigureServices((context, services) =>
{
SiteServiceRegistration.Configure(services, context.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
});
// gRPC server registration (mirrors Program.cs site section)
builder.Services.AddGrpc();
builder.Services.AddSingleton<ScadaLink.Communication.Grpc.SiteStreamGrpcServer>();
SiteServiceRegistration.Configure(builder.Services, builder.Configuration);
// Keep AkkaHostedService in DI (other services depend on it)
// but prevent it from starting by removing only its IHostedService registration.
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(builder.Services);
_host = builder.Build();
}
public void Dispose()
{
_host.Dispose();
(_host as IDisposable)?.Dispose();
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
}
@@ -333,6 +336,9 @@ public class SiteCompositionRootTests : IDisposable
new object[] { typeof(SiteStorageService) },
new object[] { typeof(ScriptCompilationService) },
new object[] { typeof(SharedScriptLibrary) },
new object[] { typeof(SiteStreamManager) },
new object[] { typeof(ISiteStreamSubscriber) },
new object[] { typeof(SiteStreamGrpcServer) },
new object[] { typeof(IDataConnectionFactory) },
new object[] { typeof(StoreAndForwardStorage) },
new object[] { typeof(StoreAndForwardService) },

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]