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:
@@ -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) },
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user