feat: wire ExternalSystem, Database, and Notify APIs into script runtime
IServiceProvider now flows through the actor chain (DeploymentManagerActor → InstanceActor → ScriptActor → ScriptExecutionActor) so scripts can resolve IExternalSystemClient, IDatabaseGateway, and INotificationDeliveryService from DI. ScriptGlobals exposes ExternalSystem, Database, Notify, and Scripts as top-level properties so scripts can use them without the Instance. prefix.
This commit is contained in:
@@ -241,7 +241,8 @@ akka {{
|
|||||||
siteRuntimeOptionsValue,
|
siteRuntimeOptionsValue,
|
||||||
dmLogger,
|
dmLogger,
|
||||||
dclManager,
|
dclManager,
|
||||||
siteHealthCollector)),
|
siteHealthCollector,
|
||||||
|
_serviceProvider)),
|
||||||
terminationMessage: PoisonPill.Instance,
|
terminationMessage: PoisonPill.Instance,
|
||||||
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
settings: ClusterSingletonManagerSettings.Create(_actorSystem!)
|
||||||
.WithRole(siteRole)
|
.WithRole(siteRole)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using ScadaLink.CentralUI;
|
|||||||
using ScadaLink.ClusterInfrastructure;
|
using ScadaLink.ClusterInfrastructure;
|
||||||
using ScadaLink.Communication;
|
using ScadaLink.Communication;
|
||||||
using ScadaLink.ConfigurationDatabase;
|
using ScadaLink.ConfigurationDatabase;
|
||||||
using ScadaLink.DataConnectionLayer;
|
|
||||||
using ScadaLink.DeploymentManager;
|
using ScadaLink.DeploymentManager;
|
||||||
using ScadaLink.ExternalSystemGateway;
|
using ScadaLink.ExternalSystemGateway;
|
||||||
using ScadaLink.HealthMonitoring;
|
using ScadaLink.HealthMonitoring;
|
||||||
@@ -15,9 +14,6 @@ using ScadaLink.InboundAPI;
|
|||||||
using ScadaLink.ManagementService;
|
using ScadaLink.ManagementService;
|
||||||
using ScadaLink.NotificationService;
|
using ScadaLink.NotificationService;
|
||||||
using ScadaLink.Security;
|
using ScadaLink.Security;
|
||||||
using ScadaLink.SiteEventLogging;
|
|
||||||
using ScadaLink.SiteRuntime;
|
|
||||||
using ScadaLink.StoreAndForward;
|
|
||||||
using ScadaLink.TemplateEngine;
|
using ScadaLink.TemplateEngine;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
@@ -98,7 +94,7 @@ try
|
|||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||||
|
|
||||||
// Options binding
|
// Options binding
|
||||||
BindSharedOptions(builder.Services, builder.Configuration);
|
SiteServiceRegistration.BindSharedOptions(builder.Services, builder.Configuration);
|
||||||
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
builder.Services.Configure<SecurityOptions>(builder.Configuration.GetSection("ScadaLink:Security"));
|
||||||
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
|
builder.Services.Configure<InboundApiOptions>(builder.Configuration.GetSection("ScadaLink:InboundApi"));
|
||||||
|
|
||||||
@@ -148,35 +144,7 @@ try
|
|||||||
|
|
||||||
builder.ConfigureServices((context, services) =>
|
builder.ConfigureServices((context, services) =>
|
||||||
{
|
{
|
||||||
// Shared components
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||||
services.AddClusterInfrastructure();
|
|
||||||
services.AddCommunication();
|
|
||||||
services.AddSiteHealthMonitoring();
|
|
||||||
services.AddExternalSystemGateway();
|
|
||||||
services.AddNotificationService();
|
|
||||||
|
|
||||||
// Health report transport: sends SiteHealthReport to SiteCommunicationActor via Akka
|
|
||||||
services.AddSingleton<ISiteIdentityProvider, SiteIdentityProvider>();
|
|
||||||
services.AddSingleton<IHealthReportTransport, AkkaHealthReportTransport>();
|
|
||||||
|
|
||||||
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
|
|
||||||
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
|
|
||||||
var siteDbPath = context.Configuration["ScadaLink:Database:SiteDbPath"] ?? "site.db";
|
|
||||||
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
|
||||||
services.AddDataConnectionLayer();
|
|
||||||
services.AddStoreAndForward();
|
|
||||||
services.AddSiteEventLogging();
|
|
||||||
|
|
||||||
// WP-13: Akka.NET bootstrap via hosted service
|
|
||||||
services.AddSingleton<AkkaHostedService>();
|
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
|
||||||
|
|
||||||
// Options binding
|
|
||||||
BindSharedOptions(services, context.Configuration);
|
|
||||||
services.Configure<SiteRuntimeOptions>(context.Configuration.GetSection("ScadaLink:SiteRuntime"));
|
|
||||||
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
|
|
||||||
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
|
|
||||||
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
@@ -197,17 +165,6 @@ finally
|
|||||||
await Log.CloseAndFlushAsync();
|
await Log.CloseAndFlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
static void BindSharedOptions(IServiceCollection services, IConfiguration config)
|
|
||||||
{
|
|
||||||
services.Configure<NodeOptions>(config.GetSection("ScadaLink:Node"));
|
|
||||||
services.Configure<ClusterOptions>(config.GetSection("ScadaLink:Cluster"));
|
|
||||||
services.Configure<DatabaseOptions>(config.GetSection("ScadaLink:Database"));
|
|
||||||
services.Configure<CommunicationOptions>(config.GetSection("ScadaLink:Communication"));
|
|
||||||
services.Configure<HealthMonitoringOptions>(config.GetSection("ScadaLink:HealthMonitoring"));
|
|
||||||
services.Configure<NotificationOptions>(config.GetSection("ScadaLink:Notification"));
|
|
||||||
services.Configure<LoggingOptions>(config.GetSection("ScadaLink:Logging"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
|
/// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
63
src/ScadaLink.Host/SiteServiceRegistration.cs
Normal file
63
src/ScadaLink.Host/SiteServiceRegistration.cs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
using ScadaLink.ClusterInfrastructure;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
using ScadaLink.DataConnectionLayer;
|
||||||
|
using ScadaLink.ExternalSystemGateway;
|
||||||
|
using ScadaLink.HealthMonitoring;
|
||||||
|
using ScadaLink.Host.Actors;
|
||||||
|
using ScadaLink.NotificationService;
|
||||||
|
using ScadaLink.SiteEventLogging;
|
||||||
|
using ScadaLink.SiteRuntime;
|
||||||
|
using ScadaLink.StoreAndForward;
|
||||||
|
|
||||||
|
namespace ScadaLink.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracted site-role DI registrations so both Program.cs and tests
|
||||||
|
/// use the same composition root.
|
||||||
|
/// </summary>
|
||||||
|
public static class SiteServiceRegistration
|
||||||
|
{
|
||||||
|
public static void Configure(IServiceCollection services, IConfiguration config)
|
||||||
|
{
|
||||||
|
// Shared components
|
||||||
|
services.AddClusterInfrastructure();
|
||||||
|
services.AddCommunication();
|
||||||
|
services.AddSiteHealthMonitoring();
|
||||||
|
services.AddExternalSystemGateway();
|
||||||
|
services.AddNotificationService();
|
||||||
|
|
||||||
|
// Health report transport: sends SiteHealthReport to SiteCommunicationActor via Akka
|
||||||
|
services.AddSingleton<ISiteIdentityProvider, SiteIdentityProvider>();
|
||||||
|
services.AddSingleton<IHealthReportTransport, AkkaHealthReportTransport>();
|
||||||
|
|
||||||
|
// Site-only components — AddSiteRuntime registers SiteStorageService with SQLite path
|
||||||
|
// and site-local repository implementations (IExternalSystemRepository, INotificationRepository)
|
||||||
|
var siteDbPath = config["ScadaLink:Database:SiteDbPath"] ?? "site.db";
|
||||||
|
services.AddSiteRuntime($"Data Source={siteDbPath}");
|
||||||
|
services.AddDataConnectionLayer();
|
||||||
|
services.AddStoreAndForward();
|
||||||
|
services.AddSiteEventLogging();
|
||||||
|
|
||||||
|
// WP-13: Akka.NET bootstrap via hosted service
|
||||||
|
services.AddSingleton<AkkaHostedService>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>());
|
||||||
|
|
||||||
|
// Options binding
|
||||||
|
BindSharedOptions(services, config);
|
||||||
|
services.Configure<SiteRuntimeOptions>(config.GetSection("ScadaLink:SiteRuntime"));
|
||||||
|
services.Configure<DataConnectionOptions>(config.GetSection("ScadaLink:DataConnection"));
|
||||||
|
services.Configure<StoreAndForwardOptions>(config.GetSection("ScadaLink:StoreAndForward"));
|
||||||
|
services.Configure<SiteEventLogOptions>(config.GetSection("ScadaLink:SiteEventLog"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void BindSharedOptions(IServiceCollection services, IConfiguration config)
|
||||||
|
{
|
||||||
|
services.Configure<NodeOptions>(config.GetSection("ScadaLink:Node"));
|
||||||
|
services.Configure<ClusterOptions>(config.GetSection("ScadaLink:Cluster"));
|
||||||
|
services.Configure<DatabaseOptions>(config.GetSection("ScadaLink:Database"));
|
||||||
|
services.Configure<CommunicationOptions>(config.GetSection("ScadaLink:Communication"));
|
||||||
|
services.Configure<HealthMonitoringOptions>(config.GetSection("ScadaLink:HealthMonitoring"));
|
||||||
|
services.Configure<NotificationOptions>(config.GetSection("ScadaLink:Notification"));
|
||||||
|
services.Configure<LoggingOptions>(config.GetSection("ScadaLink:Logging"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
private readonly ILogger<DeploymentManagerActor> _logger;
|
private readonly ILogger<DeploymentManagerActor> _logger;
|
||||||
private readonly IActorRef? _dclManager;
|
private readonly IActorRef? _dclManager;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
private readonly Dictionary<string, IActorRef> _instanceActors = new();
|
private readonly Dictionary<string, IActorRef> _instanceActors = new();
|
||||||
private int _totalDeployedCount;
|
private int _totalDeployedCount;
|
||||||
|
|
||||||
@@ -45,7 +46,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger<DeploymentManagerActor> logger,
|
ILogger<DeploymentManagerActor> logger,
|
||||||
IActorRef? dclManager = null,
|
IActorRef? dclManager = null,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
_compilationService = compilationService;
|
_compilationService = compilationService;
|
||||||
@@ -54,6 +56,7 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_options = options;
|
_options = options;
|
||||||
_dclManager = dclManager;
|
_dclManager = dclManager;
|
||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
// Lifecycle commands
|
// Lifecycle commands
|
||||||
@@ -561,7 +564,8 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
|
|||||||
_options,
|
_options,
|
||||||
loggerFactory.CreateLogger<InstanceActor>(),
|
loggerFactory.CreateLogger<InstanceActor>(),
|
||||||
_dclManager,
|
_dclManager,
|
||||||
_healthCollector));
|
_healthCollector,
|
||||||
|
_serviceProvider));
|
||||||
|
|
||||||
var actorRef = Context.ActorOf(props, instanceName);
|
var actorRef = Context.ActorOf(props, instanceName);
|
||||||
_instanceActors[instanceName] = actorRef;
|
_instanceActors[instanceName] = actorRef;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
private readonly Dictionary<string, object?> _attributes = new();
|
private readonly Dictionary<string, object?> _attributes = new();
|
||||||
private readonly Dictionary<string, string> _attributeQualities = new();
|
private readonly Dictionary<string, string> _attributeQualities = new();
|
||||||
private readonly Dictionary<string, AlarmState> _alarmStates = new();
|
private readonly Dictionary<string, AlarmState> _alarmStates = new();
|
||||||
@@ -64,7 +65,8 @@ public class InstanceActor : ReceiveActor
|
|||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
IActorRef? dclManager = null,
|
IActorRef? dclManager = null,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
_instanceUniqueName = instanceUniqueName;
|
_instanceUniqueName = instanceUniqueName;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
@@ -75,6 +77,7 @@ public class InstanceActor : ReceiveActor
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dclManager = dclManager;
|
_dclManager = dclManager;
|
||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
|
||||||
// Deserialize the flattened configuration
|
// Deserialize the flattened configuration
|
||||||
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
_configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);
|
||||||
@@ -479,7 +482,8 @@ public class InstanceActor : ReceiveActor
|
|||||||
_sharedScriptLibrary,
|
_sharedScriptLibrary,
|
||||||
_options,
|
_options,
|
||||||
_logger,
|
_logger,
|
||||||
_healthCollector));
|
_healthCollector,
|
||||||
|
_serviceProvider));
|
||||||
|
|
||||||
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
|
var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
|
||||||
_scriptActors[script.CanonicalName] = actorRef;
|
_scriptActors[script.CanonicalName] = actorRef;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
private readonly SiteRuntimeOptions _options;
|
private readonly SiteRuntimeOptions _options;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ISiteHealthCollector? _healthCollector;
|
private readonly ISiteHealthCollector? _healthCollector;
|
||||||
|
private readonly IServiceProvider? _serviceProvider;
|
||||||
|
|
||||||
private Script<object?>? _compiledScript;
|
private Script<object?>? _compiledScript;
|
||||||
private ScriptTriggerConfig? _triggerConfig;
|
private ScriptTriggerConfig? _triggerConfig;
|
||||||
@@ -49,7 +50,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
SharedScriptLibrary sharedScriptLibrary,
|
SharedScriptLibrary sharedScriptLibrary,
|
||||||
SiteRuntimeOptions options,
|
SiteRuntimeOptions options,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
_scriptName = scriptName;
|
_scriptName = scriptName;
|
||||||
_instanceName = instanceName;
|
_instanceName = instanceName;
|
||||||
@@ -59,6 +61,7 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
_options = options;
|
_options = options;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_healthCollector = healthCollector;
|
_healthCollector = healthCollector;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
_minTimeBetweenRuns = scriptConfig.MinTimeBetweenRuns;
|
||||||
|
|
||||||
// Parse trigger configuration
|
// Parse trigger configuration
|
||||||
@@ -212,7 +215,8 @@ public class ScriptActor : ReceiveActor, IWithTimers
|
|||||||
replyTo,
|
replyTo,
|
||||||
correlationId,
|
correlationId,
|
||||||
_logger,
|
_logger,
|
||||||
_healthCollector));
|
_healthCollector,
|
||||||
|
_serviceProvider));
|
||||||
|
|
||||||
Context.ActorOf(props, executionId);
|
Context.ActorOf(props, executionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using Akka.Actor;
|
using Akka.Actor;
|
||||||
using Microsoft.CodeAnalysis.Scripting;
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||||
using ScadaLink.HealthMonitoring;
|
using ScadaLink.HealthMonitoring;
|
||||||
using ScadaLink.SiteRuntime.Scripts;
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
@@ -30,7 +32,8 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
IActorRef replyTo,
|
IActorRef replyTo,
|
||||||
string correlationId,
|
string correlationId,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
ISiteHealthCollector? healthCollector = null)
|
ISiteHealthCollector? healthCollector = null,
|
||||||
|
IServiceProvider? serviceProvider = null)
|
||||||
{
|
{
|
||||||
// Immediately begin execution
|
// Immediately begin execution
|
||||||
var self = Self;
|
var self = Self;
|
||||||
@@ -39,7 +42,7 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
ExecuteScript(
|
ExecuteScript(
|
||||||
scriptName, instanceName, compiledScript, parameters, callDepth,
|
scriptName, instanceName, compiledScript, parameters, callDepth,
|
||||||
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
instanceActor, sharedScriptLibrary, options, replyTo, correlationId,
|
||||||
self, parent, logger, healthCollector);
|
self, parent, logger, healthCollector, serviceProvider);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ExecuteScript(
|
private static void ExecuteScript(
|
||||||
@@ -56,16 +59,31 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
IActorRef self,
|
IActorRef self,
|
||||||
IActorRef parent,
|
IActorRef parent,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
ISiteHealthCollector? healthCollector)
|
ISiteHealthCollector? healthCollector,
|
||||||
|
IServiceProvider? serviceProvider)
|
||||||
{
|
{
|
||||||
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
var timeout = TimeSpan.FromSeconds(options.ScriptExecutionTimeoutSeconds);
|
||||||
|
|
||||||
// CTS must be created inside the async lambda so it outlives this method
|
// CTS must be created inside the async lambda so it outlives this method
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
|
IServiceScope? serviceScope = null;
|
||||||
using var cts = new CancellationTokenSource(timeout);
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Resolve integration services from DI (scoped lifetime)
|
||||||
|
IExternalSystemClient? externalSystemClient = null;
|
||||||
|
IDatabaseGateway? databaseGateway = null;
|
||||||
|
INotificationDeliveryService? notificationService = null;
|
||||||
|
|
||||||
|
if (serviceProvider != null)
|
||||||
|
{
|
||||||
|
serviceScope = serviceProvider.CreateScope();
|
||||||
|
externalSystemClient = serviceScope.ServiceProvider.GetService<IExternalSystemClient>();
|
||||||
|
databaseGateway = serviceScope.ServiceProvider.GetService<IDatabaseGateway>();
|
||||||
|
notificationService = serviceScope.ServiceProvider.GetService<INotificationDeliveryService>();
|
||||||
|
}
|
||||||
|
|
||||||
var context = new ScriptRuntimeContext(
|
var context = new ScriptRuntimeContext(
|
||||||
instanceActor,
|
instanceActor,
|
||||||
self,
|
self,
|
||||||
@@ -74,7 +92,10 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
options.MaxScriptCallDepth,
|
options.MaxScriptCallDepth,
|
||||||
timeout,
|
timeout,
|
||||||
instanceName,
|
instanceName,
|
||||||
logger);
|
logger,
|
||||||
|
externalSystemClient,
|
||||||
|
databaseGateway,
|
||||||
|
notificationService);
|
||||||
|
|
||||||
var globals = new ScriptGlobals
|
var globals = new ScriptGlobals
|
||||||
{
|
{
|
||||||
@@ -123,6 +144,8 @@ public class ScriptExecutionActor : ReceiveActor
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
// Dispose the DI scope (and scoped services) after script execution completes
|
||||||
|
serviceScope?.Dispose();
|
||||||
// Stop self after execution completes
|
// Stop self after execution completes
|
||||||
self.Tell(PoisonPill.Instance);
|
self.Tell(PoisonPill.Instance);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,4 +178,28 @@ public class ScriptGlobals
|
|||||||
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
|
public IReadOnlyDictionary<string, object?> Parameters { get; set; } =
|
||||||
new Dictionary<string, object?>();
|
new Dictionary<string, object?>();
|
||||||
public CancellationToken CancellationToken { get; set; }
|
public CancellationToken CancellationToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
||||||
|
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
||||||
|
/// </summary>
|
||||||
|
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level Database access for scripts (delegates to Instance.Database).
|
||||||
|
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
|
||||||
|
/// </summary>
|
||||||
|
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level Notify access for scripts (delegates to Instance.Notify).
|
||||||
|
/// Usage: Notify.To("listName").Send("subject", "message")
|
||||||
|
/// </summary>
|
||||||
|
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
|
||||||
|
/// Usage: Scripts.CallShared("scriptName", params)
|
||||||
|
/// </summary>
|
||||||
|
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
||||||
}
|
}
|
||||||
|
|||||||
197
tests/ScadaLink.Host.Tests/ActorPathTests.cs
Normal file
197
tests/ScadaLink.Host.Tests/ActorPathTests.cs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
using Akka.Actor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.Host;
|
||||||
|
using ScadaLink.Host.Actors;
|
||||||
|
|
||||||
|
namespace ScadaLink.Host.Tests;
|
||||||
|
|
||||||
|
[CollectionDefinition("ActorSystem")]
|
||||||
|
public class ActorSystemCollection : ICollectionFixture<object> { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that all expected Central-role actors are created at the correct paths
|
||||||
|
/// when AkkaHostedService starts.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ActorSystem")]
|
||||||
|
public class CentralActorPathTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private WebApplicationFactory<Program>? _factory;
|
||||||
|
private ActorSystem? _actorSystem;
|
||||||
|
private string? _previousEnv;
|
||||||
|
|
||||||
|
public Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
|
||||||
|
_factory = new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||||
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520",
|
||||||
|
["ScadaLink:Cluster:MinNrOfMembers"] = "1",
|
||||||
|
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||||
|
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||||
|
["ScadaLink:Security:LdapServer"] = "localhost",
|
||||||
|
["ScadaLink:Security:LdapPort"] = "3893",
|
||||||
|
["ScadaLink:Security:LdapUseTls"] = "false",
|
||||||
|
["ScadaLink:Security:AllowInsecureLdap"] = "true",
|
||||||
|
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||||
|
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Replace SQL Server with in-memory database
|
||||||
|
var descriptorsToRemove = services
|
||||||
|
.Where(d =>
|
||||||
|
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
||||||
|
d.ServiceType == typeof(DbContextOptions) ||
|
||||||
|
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
||||||
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||||
|
.ToList();
|
||||||
|
foreach (var d in descriptorsToRemove)
|
||||||
|
services.Remove(d);
|
||||||
|
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase($"ActorPathTests_{Guid.NewGuid()}"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// CreateClient triggers host startup including AkkaHostedService
|
||||||
|
_ = _factory.CreateClient();
|
||||||
|
|
||||||
|
var akkaService = _factory.Services.GetRequiredService<AkkaHostedService>();
|
||||||
|
_actorSystem = akkaService.ActorSystem;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
_factory?.Dispose();
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralActors_DeadLetterMonitor_Exists()
|
||||||
|
=> await AssertActorExists("/user/dead-letter-monitor");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralActors_CentralCommunication_Exists()
|
||||||
|
=> await AssertActorExists("/user/central-communication");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralActors_Management_Exists()
|
||||||
|
=> await AssertActorExists("/user/management");
|
||||||
|
|
||||||
|
private async Task AssertActorExists(string path)
|
||||||
|
{
|
||||||
|
Assert.NotNull(_actorSystem);
|
||||||
|
var selection = _actorSystem!.ActorSelection(path);
|
||||||
|
var identity = await selection.Ask<ActorIdentity>(
|
||||||
|
new Identify(path), TimeSpan.FromSeconds(5));
|
||||||
|
Assert.NotNull(identity.Subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that all expected Site-role actors are created at the correct paths
|
||||||
|
/// when AkkaHostedService starts.
|
||||||
|
/// </summary>
|
||||||
|
[Collection("ActorSystem")]
|
||||||
|
public class SiteActorPathTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private IHost? _host;
|
||||||
|
private ActorSystem? _actorSystem;
|
||||||
|
private string _tempDbPath = null!;
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_tempDbPath = Path.Combine(Path.GetTempPath(), $"scadalink_actor_test_{Guid.NewGuid()}.db");
|
||||||
|
|
||||||
|
var builder = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder();
|
||||||
|
builder.ConfigureAppConfiguration(config =>
|
||||||
|
{
|
||||||
|
config.Sources.Clear();
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaLink:Node:Role"] = "Site",
|
||||||
|
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||||
|
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||||
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:25510",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:25520",
|
||||||
|
["ScadaLink:Cluster:MinNrOfMembers"] = "1",
|
||||||
|
["ScadaLink:Database:SiteDbPath"] = _tempDbPath,
|
||||||
|
// Configure a dummy central contact point to trigger ClusterClient creation
|
||||||
|
["ScadaLink:Communication:CentralContactPoints:0"] = "akka.tcp://scadalink@localhost:25510",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.ConfigureServices((context, services) =>
|
||||||
|
{
|
||||||
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
_host = builder.Build();
|
||||||
|
await _host.StartAsync();
|
||||||
|
|
||||||
|
var akkaService = _host.Services.GetRequiredService<AkkaHostedService>();
|
||||||
|
_actorSystem = akkaService.ActorSystem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_host != null)
|
||||||
|
{
|
||||||
|
await _host.StopAsync();
|
||||||
|
_host.Dispose();
|
||||||
|
}
|
||||||
|
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_DeadLetterMonitor_Exists()
|
||||||
|
=> await AssertActorExists("/user/dead-letter-monitor");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_DclManager_Exists()
|
||||||
|
=> await AssertActorExists("/user/dcl-manager");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_DeploymentManagerSingleton_Exists()
|
||||||
|
=> await AssertActorExists("/user/deployment-manager-singleton");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_DeploymentManagerProxy_Exists()
|
||||||
|
=> await AssertActorExists("/user/deployment-manager-proxy");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_SiteCommunication_Exists()
|
||||||
|
=> await AssertActorExists("/user/site-communication");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SiteActors_CentralClusterClient_Exists()
|
||||||
|
=> await AssertActorExists("/user/central-cluster-client");
|
||||||
|
|
||||||
|
private async Task AssertActorExists(string path)
|
||||||
|
{
|
||||||
|
Assert.NotNull(_actorSystem);
|
||||||
|
var selection = _actorSystem!.ActorSelection(path);
|
||||||
|
var identity = await selection.Ask<ActorIdentity>(
|
||||||
|
new Identify(path), TimeSpan.FromSeconds(5));
|
||||||
|
Assert.NotNull(identity.Subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
429
tests/ScadaLink.Host.Tests/CompositionRootTests.cs
Normal file
429
tests/ScadaLink.Host.Tests/CompositionRootTests.cs
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ScadaLink.ClusterInfrastructure;
|
||||||
|
using ScadaLink.Communication;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.Commons.Interfaces.Services;
|
||||||
|
using ScadaLink.ConfigurationDatabase;
|
||||||
|
using ScadaLink.DataConnectionLayer;
|
||||||
|
using ScadaLink.DeploymentManager;
|
||||||
|
using ScadaLink.ExternalSystemGateway;
|
||||||
|
using ScadaLink.HealthMonitoring;
|
||||||
|
using ScadaLink.Host;
|
||||||
|
using ScadaLink.Host.Actors;
|
||||||
|
using ScadaLink.InboundAPI;
|
||||||
|
using ScadaLink.ManagementService;
|
||||||
|
using ScadaLink.NotificationService;
|
||||||
|
using ScadaLink.Security;
|
||||||
|
using ScadaLink.SiteEventLogging;
|
||||||
|
using ScadaLink.SiteRuntime;
|
||||||
|
using ScadaLink.SiteRuntime.Persistence;
|
||||||
|
using ScadaLink.SiteRuntime.Repositories;
|
||||||
|
using ScadaLink.SiteRuntime.Scripts;
|
||||||
|
using ScadaLink.StoreAndForward;
|
||||||
|
using ScadaLink.TemplateEngine;
|
||||||
|
using ScadaLink.TemplateEngine.Flattening;
|
||||||
|
using ScadaLink.TemplateEngine.Services;
|
||||||
|
using ScadaLink.TemplateEngine.Validation;
|
||||||
|
|
||||||
|
namespace ScadaLink.Host.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes AkkaHostedService from running as a hosted service while keeping it
|
||||||
|
/// resolvable in DI (other services like AkkaHealthReportTransport depend on it).
|
||||||
|
/// </summary>
|
||||||
|
internal static class AkkaHostedServiceRemover
|
||||||
|
{
|
||||||
|
internal static void RemoveAkkaHostedServiceOnly(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Pattern used in Program.cs:
|
||||||
|
// services.AddSingleton<AkkaHostedService>(); // index N
|
||||||
|
// services.AddHostedService(sp => sp.GetRequiredService<AkkaHostedService>()); // index N+1
|
||||||
|
// We keep the singleton so DI resolution works, but remove the IHostedService
|
||||||
|
// factory so StartAsync is never called.
|
||||||
|
int akkaIndex = -1;
|
||||||
|
for (int i = 0; i < services.Count; i++)
|
||||||
|
{
|
||||||
|
if (services[i].ServiceType == typeof(AkkaHostedService))
|
||||||
|
{
|
||||||
|
akkaIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (akkaIndex < 0) return;
|
||||||
|
|
||||||
|
// The IHostedService factory is the next registration after the singleton
|
||||||
|
for (int i = akkaIndex + 1; i < services.Count; i++)
|
||||||
|
{
|
||||||
|
if (services[i].ServiceType == typeof(IHostedService)
|
||||||
|
&& services[i].ImplementationFactory != null)
|
||||||
|
{
|
||||||
|
services.RemoveAt(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies every expected DI service resolves from the Central composition root.
|
||||||
|
/// Uses WebApplicationFactory to exercise the real Program.cs pipeline.
|
||||||
|
/// </summary>
|
||||||
|
public class CentralCompositionRootTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly WebApplicationFactory<Program> _factory;
|
||||||
|
private readonly string? _previousEnv;
|
||||||
|
|
||||||
|
public CentralCompositionRootTests()
|
||||||
|
{
|
||||||
|
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
|
||||||
|
|
||||||
|
_factory = new WebApplicationFactory<Program>()
|
||||||
|
.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureAppConfiguration((_, config) =>
|
||||||
|
{
|
||||||
|
config.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ScadaLink:Node:NodeHostname"] = "localhost",
|
||||||
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:0"] = "akka.tcp://scadalink@localhost:2551",
|
||||||
|
["ScadaLink:Cluster:SeedNodes:1"] = "akka.tcp://scadalink@localhost:2552",
|
||||||
|
["ScadaLink:Database:SkipMigrations"] = "true",
|
||||||
|
["ScadaLink:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
|
||||||
|
["ScadaLink:Security:LdapServer"] = "localhost",
|
||||||
|
["ScadaLink:Security:LdapPort"] = "3893",
|
||||||
|
["ScadaLink:Security:LdapUseTls"] = "false",
|
||||||
|
["ScadaLink:Security:AllowInsecureLdap"] = "true",
|
||||||
|
["ScadaLink:Security:LdapSearchBase"] = "dc=scadalink,dc=local",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.UseSetting("ScadaLink:Node:Role", "Central");
|
||||||
|
builder.UseSetting("ScadaLink:Database:SkipMigrations", "true");
|
||||||
|
builder.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
// Replace SQL Server with in-memory database
|
||||||
|
var descriptorsToRemove = services
|
||||||
|
.Where(d =>
|
||||||
|
d.ServiceType == typeof(DbContextOptions<ScadaLinkDbContext>) ||
|
||||||
|
d.ServiceType == typeof(DbContextOptions) ||
|
||||||
|
d.ServiceType == typeof(ScadaLinkDbContext) ||
|
||||||
|
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
|
||||||
|
.ToList();
|
||||||
|
foreach (var d in descriptorsToRemove)
|
||||||
|
services.Remove(d);
|
||||||
|
|
||||||
|
services.AddDbContext<ScadaLinkDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase($"CompositionRootTests_{Guid.NewGuid()}"));
|
||||||
|
|
||||||
|
// Keep AkkaHostedService in DI (other services depend on it)
|
||||||
|
// but prevent it from starting by removing only its IHostedService registration.
|
||||||
|
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger host build
|
||||||
|
_ = _factory.Server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_factory.Dispose();
|
||||||
|
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Singletons ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CentralSingletonServices))]
|
||||||
|
public void Central_ResolveSingleton(Type serviceType)
|
||||||
|
{
|
||||||
|
var service = _factory.Services.GetService(serviceType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> CentralSingletonServices => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(CommunicationService) },
|
||||||
|
new object[] { typeof(ISiteHealthCollector) },
|
||||||
|
new object[] { typeof(CentralHealthAggregator) },
|
||||||
|
new object[] { typeof(ICentralHealthAggregator) },
|
||||||
|
new object[] { typeof(OperationLockManager) },
|
||||||
|
new object[] { typeof(OAuth2TokenService) },
|
||||||
|
new object[] { typeof(InboundScriptExecutor) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Scoped services ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CentralScopedServices))]
|
||||||
|
public void Central_ResolveScoped(Type serviceType)
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var service = scope.ServiceProvider.GetService(serviceType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> CentralScopedServices => new[]
|
||||||
|
{
|
||||||
|
// TemplateEngine
|
||||||
|
new object[] { typeof(TemplateService) },
|
||||||
|
new object[] { typeof(SharedScriptService) },
|
||||||
|
new object[] { typeof(InstanceService) },
|
||||||
|
new object[] { typeof(SiteService) },
|
||||||
|
new object[] { typeof(AreaService) },
|
||||||
|
new object[] { typeof(TemplateDeletionService) },
|
||||||
|
// DeploymentManager
|
||||||
|
new object[] { typeof(IFlatteningPipeline) },
|
||||||
|
new object[] { typeof(DeploymentService) },
|
||||||
|
new object[] { typeof(ArtifactDeploymentService) },
|
||||||
|
// Security
|
||||||
|
new object[] { typeof(LdapAuthService) },
|
||||||
|
new object[] { typeof(JwtTokenService) },
|
||||||
|
new object[] { typeof(RoleMapper) },
|
||||||
|
// InboundAPI
|
||||||
|
new object[] { typeof(ApiKeyValidator) },
|
||||||
|
new object[] { typeof(RouteHelper) },
|
||||||
|
// ExternalSystemGateway
|
||||||
|
new object[] { typeof(ExternalSystemClient) },
|
||||||
|
new object[] { typeof(IExternalSystemClient) },
|
||||||
|
new object[] { typeof(DatabaseGateway) },
|
||||||
|
new object[] { typeof(IDatabaseGateway) },
|
||||||
|
// NotificationService
|
||||||
|
new object[] { typeof(NotificationDeliveryService) },
|
||||||
|
new object[] { typeof(INotificationDeliveryService) },
|
||||||
|
// ConfigurationDatabase repositories
|
||||||
|
new object[] { typeof(ScadaLinkDbContext) },
|
||||||
|
new object[] { typeof(ISecurityRepository) },
|
||||||
|
new object[] { typeof(ICentralUiRepository) },
|
||||||
|
new object[] { typeof(ITemplateEngineRepository) },
|
||||||
|
new object[] { typeof(IDeploymentManagerRepository) },
|
||||||
|
new object[] { typeof(ISiteRepository) },
|
||||||
|
new object[] { typeof(IExternalSystemRepository) },
|
||||||
|
new object[] { typeof(INotificationRepository) },
|
||||||
|
new object[] { typeof(IInboundApiRepository) },
|
||||||
|
new object[] { typeof(IAuditService) },
|
||||||
|
new object[] { typeof(IInstanceLocator) },
|
||||||
|
// CentralUI
|
||||||
|
new object[] { typeof(AuthenticationStateProvider) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Transient services ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CentralTransientServices))]
|
||||||
|
public void Central_ResolveTransient(Type serviceType)
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var service = scope.ServiceProvider.GetService(serviceType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> CentralTransientServices => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(FlatteningService) },
|
||||||
|
new object[] { typeof(DiffService) },
|
||||||
|
new object[] { typeof(RevisionHashService) },
|
||||||
|
new object[] { typeof(ScriptCompiler) },
|
||||||
|
new object[] { typeof(SemanticValidator) },
|
||||||
|
new object[] { typeof(ValidationService) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Options ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(CentralOptions))]
|
||||||
|
public void Central_ResolveOptions(Type optionsType)
|
||||||
|
{
|
||||||
|
var service = _factory.Services.GetService(optionsType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> CentralOptions => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(IOptions<NodeOptions>) },
|
||||||
|
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||||
|
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||||
|
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||||
|
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||||
|
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||||
|
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||||
|
new object[] { typeof(IOptions<SecurityOptions>) },
|
||||||
|
new object[] { typeof(IOptions<InboundApiOptions>) },
|
||||||
|
new object[] { typeof(IOptions<ManagementServiceOptions>) },
|
||||||
|
new object[] { typeof(IOptions<ExternalSystemGatewayOptions>) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hosted services ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Central_CentralHealthAggregator_RegisteredAsHostedService()
|
||||||
|
{
|
||||||
|
var hostedServices = _factory.Services.GetServices<IHostedService>();
|
||||||
|
Assert.Contains(hostedServices, s => s.GetType() == typeof(CentralHealthAggregator));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteCompositionRootTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly IHost _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 =>
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
_host = builder.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_host.Dispose();
|
||||||
|
try { File.Delete(_tempDbPath); } catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Singletons ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SiteSingletonServices))]
|
||||||
|
public void Site_ResolveSingleton(Type serviceType)
|
||||||
|
{
|
||||||
|
var service = _host.Services.GetService(serviceType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> SiteSingletonServices => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(CommunicationService) },
|
||||||
|
new object[] { typeof(ISiteHealthCollector) },
|
||||||
|
new object[] { typeof(SiteStorageService) },
|
||||||
|
new object[] { typeof(ScriptCompilationService) },
|
||||||
|
new object[] { typeof(SharedScriptLibrary) },
|
||||||
|
new object[] { typeof(IDataConnectionFactory) },
|
||||||
|
new object[] { typeof(StoreAndForwardStorage) },
|
||||||
|
new object[] { typeof(StoreAndForwardService) },
|
||||||
|
new object[] { typeof(ReplicationService) },
|
||||||
|
new object[] { typeof(ISiteEventLogger) },
|
||||||
|
new object[] { typeof(IEventLogQueryService) },
|
||||||
|
new object[] { typeof(OAuth2TokenService) },
|
||||||
|
new object[] { typeof(ISiteIdentityProvider) },
|
||||||
|
new object[] { typeof(IHealthReportTransport) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Scoped services ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SiteScopedServices))]
|
||||||
|
public void Site_ResolveScoped(Type serviceType)
|
||||||
|
{
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var service = scope.ServiceProvider.GetService(serviceType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> SiteScopedServices => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(IExternalSystemRepository) },
|
||||||
|
new object[] { typeof(INotificationRepository) },
|
||||||
|
new object[] { typeof(ExternalSystemClient) },
|
||||||
|
new object[] { typeof(IExternalSystemClient) },
|
||||||
|
new object[] { typeof(DatabaseGateway) },
|
||||||
|
new object[] { typeof(IDatabaseGateway) },
|
||||||
|
new object[] { typeof(NotificationDeliveryService) },
|
||||||
|
new object[] { typeof(INotificationDeliveryService) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Implementation type assertions ---
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_ExternalSystemRepository_IsSiteImplementation()
|
||||||
|
{
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<IExternalSystemRepository>();
|
||||||
|
Assert.IsType<SiteExternalSystemRepository>(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Site_NotificationRepository_IsSiteImplementation()
|
||||||
|
{
|
||||||
|
using var scope = _host.Services.CreateScope();
|
||||||
|
var repo = scope.ServiceProvider.GetRequiredService<INotificationRepository>();
|
||||||
|
Assert.IsType<SiteNotificationRepository>(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Options ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SiteOptions))]
|
||||||
|
public void Site_ResolveOptions(Type optionsType)
|
||||||
|
{
|
||||||
|
var service = _host.Services.GetService(optionsType);
|
||||||
|
Assert.NotNull(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> SiteOptions => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(IOptions<NodeOptions>) },
|
||||||
|
new object[] { typeof(IOptions<ClusterOptions>) },
|
||||||
|
new object[] { typeof(IOptions<DatabaseOptions>) },
|
||||||
|
new object[] { typeof(IOptions<CommunicationOptions>) },
|
||||||
|
new object[] { typeof(IOptions<HealthMonitoringOptions>) },
|
||||||
|
new object[] { typeof(IOptions<NotificationOptions>) },
|
||||||
|
new object[] { typeof(IOptions<LoggingOptions>) },
|
||||||
|
new object[] { typeof(IOptions<SiteRuntimeOptions>) },
|
||||||
|
new object[] { typeof(IOptions<DataConnectionOptions>) },
|
||||||
|
new object[] { typeof(IOptions<StoreAndForwardOptions>) },
|
||||||
|
new object[] { typeof(IOptions<SiteEventLogOptions>) },
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hosted services ---
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(SiteHostedServices))]
|
||||||
|
public void Site_HostedServiceRegistered(Type expectedType)
|
||||||
|
{
|
||||||
|
var hostedServices = _host.Services.GetServices<IHostedService>();
|
||||||
|
Assert.Contains(hostedServices, s => s.GetType() == expectedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> SiteHostedServices => new[]
|
||||||
|
{
|
||||||
|
new object[] { typeof(HealthReportSender) },
|
||||||
|
new object[] { typeof(SiteStorageInitializer) },
|
||||||
|
new object[] { typeof(EventLogPurgeService) },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,16 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using ScadaLink.ClusterInfrastructure;
|
|
||||||
using ScadaLink.Communication;
|
|
||||||
using ScadaLink.DataConnectionLayer;
|
|
||||||
using ScadaLink.ExternalSystemGateway;
|
|
||||||
using ScadaLink.HealthMonitoring;
|
|
||||||
using ScadaLink.Host;
|
using ScadaLink.Host;
|
||||||
using ScadaLink.NotificationService;
|
|
||||||
using ScadaLink.SiteEventLogging;
|
|
||||||
using ScadaLink.SiteRuntime;
|
|
||||||
using ScadaLink.StoreAndForward;
|
|
||||||
|
|
||||||
namespace ScadaLink.Host.Tests;
|
namespace ScadaLink.Host.Tests;
|
||||||
|
|
||||||
@@ -83,35 +74,12 @@ public class HostStartupTests : IDisposable
|
|||||||
["ScadaLink:Node:Role"] = "Site",
|
["ScadaLink:Node:Role"] = "Site",
|
||||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||||
["ScadaLink:Node:RemotingPort"] = "8082",
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
builder.ConfigureServices((context, services) =>
|
builder.ConfigureServices((context, services) =>
|
||||||
{
|
{
|
||||||
// Shared components
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||||
services.AddClusterInfrastructure();
|
|
||||||
services.AddCommunication();
|
|
||||||
services.AddHealthMonitoring();
|
|
||||||
services.AddExternalSystemGateway();
|
|
||||||
services.AddNotificationService();
|
|
||||||
|
|
||||||
// Site-only components
|
|
||||||
services.AddSiteRuntime();
|
|
||||||
services.AddDataConnectionLayer();
|
|
||||||
services.AddStoreAndForward();
|
|
||||||
services.AddSiteEventLogging();
|
|
||||||
|
|
||||||
// Options binding (mirrors Program.cs site path)
|
|
||||||
services.Configure<NodeOptions>(context.Configuration.GetSection("ScadaLink:Node"));
|
|
||||||
services.Configure<ClusterOptions>(context.Configuration.GetSection("ScadaLink:Cluster"));
|
|
||||||
services.Configure<DatabaseOptions>(context.Configuration.GetSection("ScadaLink:Database"));
|
|
||||||
services.Configure<CommunicationOptions>(context.Configuration.GetSection("ScadaLink:Communication"));
|
|
||||||
services.Configure<HealthMonitoringOptions>(context.Configuration.GetSection("ScadaLink:HealthMonitoring"));
|
|
||||||
services.Configure<NotificationOptions>(context.Configuration.GetSection("ScadaLink:Notification"));
|
|
||||||
services.Configure<LoggingOptions>(context.Configuration.GetSection("ScadaLink:Logging"));
|
|
||||||
services.Configure<DataConnectionOptions>(context.Configuration.GetSection("ScadaLink:DataConnection"));
|
|
||||||
services.Configure<StoreAndForwardOptions>(context.Configuration.GetSection("ScadaLink:StoreAndForward"));
|
|
||||||
services.Configure<SiteEventLogOptions>(context.Configuration.GetSection("ScadaLink:SiteEventLog"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
@@ -134,20 +102,12 @@ public class HostStartupTests : IDisposable
|
|||||||
["ScadaLink:Node:Role"] = "Site",
|
["ScadaLink:Node:Role"] = "Site",
|
||||||
["ScadaLink:Node:NodeHostname"] = "test-site",
|
["ScadaLink:Node:NodeHostname"] = "test-site",
|
||||||
["ScadaLink:Node:SiteId"] = "TestSite",
|
["ScadaLink:Node:SiteId"] = "TestSite",
|
||||||
["ScadaLink:Node:RemotingPort"] = "8082",
|
["ScadaLink:Node:RemotingPort"] = "0",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
builder.ConfigureServices((context, services) =>
|
builder.ConfigureServices((context, services) =>
|
||||||
{
|
{
|
||||||
services.AddClusterInfrastructure();
|
SiteServiceRegistration.Configure(services, context.Configuration);
|
||||||
services.AddCommunication();
|
|
||||||
services.AddHealthMonitoring();
|
|
||||||
services.AddExternalSystemGateway();
|
|
||||||
services.AddNotificationService();
|
|
||||||
services.AddSiteRuntime();
|
|
||||||
services.AddDataConnectionLayer();
|
|
||||||
services.AddStoreAndForward();
|
|
||||||
services.AddSiteEventLogging();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
<PackageReference Include="Akka.TestKit.Xunit2" Version="1.5.62" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
|||||||
Reference in New Issue
Block a user