diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
index 0e9dab79..1866e878 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs
@@ -1,4 +1,5 @@
using Akka.Actor;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
@@ -10,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
@@ -456,6 +458,10 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
if (result.Success)
{
+ // M1.6: operational `deployment` event — deploy succeeded.
+ LogDeploymentEvent("Info", result.InstanceName,
+ $"Instance {result.InstanceName} deployed (deploymentId={result.DeploymentId})");
+
result.OriginalSender.Tell(new DeploymentStatusResponse(
result.DeploymentId,
result.InstanceName,
@@ -469,6 +475,11 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
"Failed to persist deployment {DeploymentId} for {Instance}: {Error}",
result.DeploymentId, result.InstanceName, result.Error);
+ // M1.6: operational `deployment` event — deploy failed.
+ LogDeploymentEvent("Error", result.InstanceName,
+ $"Instance {result.InstanceName} deploy failed (deploymentId={result.DeploymentId})",
+ result.Error);
+
// Persistence failed — undo the optimistic actor creation and counter bump so
// the site does not advertise an instance it cannot durably recover.
if (_instanceActors.Remove(result.InstanceName, out var orphan))
@@ -504,7 +515,17 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
+ {
_replicationActor?.Tell(new ReplicateConfigSetEnabled(instanceName, false));
+ // M1.6: operational `deployment` event — disable succeeded.
+ LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} disabled");
+ }
+ else
+ {
+ LogDeploymentEvent("Error", instanceName,
+ $"Instance {instanceName} disable failed",
+ t.Exception?.GetBaseException().Message);
+ }
return new InstanceLifecycleResponse(
command.CommandId,
@@ -551,6 +572,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
if (result.Error != null || result.Config == null)
{
var error = result.Error ?? $"No deployed config found for {instanceName}";
+ // M1.6: operational `deployment` event — enable failed.
+ LogDeploymentEvent("Error", instanceName,
+ $"Instance {instanceName} enable failed", error);
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, false, error, DateTimeOffset.UtcNow));
return;
@@ -562,6 +586,9 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
}
UpdateInstanceCounts();
+ // M1.6: operational `deployment` event — enable succeeded.
+ LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} enabled");
+
result.OriginalSender.Tell(new InstanceLifecycleResponse(
result.Command.CommandId, instanceName, true, null, DateTimeOffset.UtcNow));
@@ -588,7 +615,17 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
{
if (t.IsCompletedSuccessfully)
+ {
_replicationActor?.Tell(new ReplicateConfigRemove(instanceName));
+ // M1.6: operational `deployment` event — delete succeeded.
+ LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} deleted");
+ }
+ else
+ {
+ LogDeploymentEvent("Error", instanceName,
+ $"Instance {instanceName} delete failed",
+ t.Exception?.GetBaseException().Message);
+ }
return new InstanceLifecycleResponse(
command.CommandId,
@@ -601,6 +638,21 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers
_logger.LogInformation("Instance {Instance} deleted", instanceName);
}
+ ///
+ /// M1.6: fire-and-forget a deployment operational event to the optional
+ /// on a deploy/enable/disable/delete outcome.
+ /// Resolved optionally and never awaited so a logging failure cannot affect the
+ /// deployment pipeline (matching the established ScriptActor/ScriptExecutionActor
+ /// pattern). Only reads the immutable _serviceProvider field, so it is
+ /// safe to call from the PipeTo continuations that report disable/delete
+ /// outcomes off the actor thread.
+ ///
+ private void LogDeploymentEvent(string severity, string instanceName, string message, string? details = null)
+ {
+ _ = _serviceProvider?.GetService()?.LogEventAsync(
+ "deployment", severity, instanceName, "DeploymentManagerActor", message, details);
+ }
+
///
/// DeploymentManager-006: answers a central query for the instance's
/// currently-applied deployment identity. The site's deployed-config store
diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
index 310eb9d8..e6f9a3fc 100644
--- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/InstanceActor.cs
@@ -1,5 +1,6 @@
using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
@@ -9,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
+using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
@@ -164,6 +166,11 @@ public class InstanceActor : ReceiveActor
base.PreStart();
_logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);
+ // M1.6: operational `instance_lifecycle` event — instance started.
+ // An instance starts on deploy, on enable (DeploymentManager re-creates
+ // the actor), and on failover/restart; this single point covers them all.
+ LogLifecycleEvent($"Instance {_instanceUniqueName} started");
+
// Asynchronously load static overrides from SQLite and pipe to self
var self = Self;
_storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
@@ -180,6 +187,29 @@ public class InstanceActor : ReceiveActor
SubscribeToDcl();
}
+ ///
+ protected override void PostStop()
+ {
+ // M1.6: operational `instance_lifecycle` event — instance stopped. An
+ // instance stops on disable, delete, redeployment, and graceful shutdown;
+ // this single point covers them all.
+ LogLifecycleEvent($"Instance {_instanceUniqueName} stopped");
+ base.PostStop();
+ }
+
+ ///
+ /// M1.6: fire-and-forget an instance_lifecycle operational event to the
+ /// optional . Resolved optionally and never
+ /// awaited so a logging failure cannot affect the instance lifecycle
+ /// (matching the established ScriptActor/ScriptExecutionActor pattern).
+ ///
+ private void LogLifecycleEvent(string message)
+ {
+ _ = _serviceProvider?.GetService()?.LogEventAsync(
+ "instance_lifecycle", "Info", _instanceUniqueName,
+ $"InstanceActor:{_instanceUniqueName}", message);
+ }
+
///
protected override SupervisorStrategy SupervisorStrategy()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
index 2b2191ca..d2a4613e 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/DeploymentManagerActorTests.cs
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -44,7 +45,8 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
- private IActorRef CreateDeploymentManager(SiteRuntimeOptions? options = null)
+ private IActorRef CreateDeploymentManager(
+ SiteRuntimeOptions? options = null, IServiceProvider? serviceProvider = null)
{
options ??= new SiteRuntimeOptions();
return ActorOf(Props.Create(() => new DeploymentManagerActor(
@@ -53,7 +55,12 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
_sharedScriptLibrary,
null, // no stream manager in tests
options,
- NullLogger.Instance)));
+ NullLogger.Instance,
+ null,
+ null,
+ null,
+ serviceProvider,
+ null)));
}
private static string MakeConfigJson(string instanceName)
@@ -171,6 +178,67 @@ public class DeploymentManagerActorTests : TestKit, IDisposable
Assert.Equal("NewPump", response.InstanceUniqueName);
}
+ // ── M1.6: site event log `deployment` category ─────────────────────────
+
+ [Fact]
+ public async Task DeploymentManager_DeploySuccess_EmitsDeploymentSiteEvent()
+ {
+ var siteLog = new FakeSiteEventLogger();
+ var actor = CreateDeploymentManager(serviceProvider: new SingleServiceProvider(siteLog));
+
+ await Task.Delay(500); // wait for empty startup
+
+ actor.Tell(new DeployInstanceCommand(
+ "dep-evt-1", "AuditedPump", "sha256:xyz",
+ MakeConfigJson("AuditedPump"), "admin", DateTimeOffset.UtcNow));
+ var response = ExpectMsg(TimeSpan.FromSeconds(5));
+ Assert.Equal(DeploymentStatus.Success, response.Status);
+
+ AwaitAssert(() =>
+ {
+ var rows = siteLog.OfType("deployment");
+ Assert.Contains(rows, r =>
+ r.Severity == "Info" &&
+ r.InstanceId == "AuditedPump" &&
+ r.Source == "DeploymentManagerActor" &&
+ r.Message.Contains("deploy", StringComparison.OrdinalIgnoreCase));
+ }, TimeSpan.FromSeconds(2));
+ }
+
+ [Fact]
+ public async Task DeploymentManager_DisableEnableDelete_EmitDeploymentSiteEvents()
+ {
+ var siteLog = new FakeSiteEventLogger();
+ var actor = CreateDeploymentManager(serviceProvider: new SingleServiceProvider(siteLog));
+
+ await Task.Delay(500);
+
+ actor.Tell(new DeployInstanceCommand(
+ "dep-evt-2", "EvtPump", "sha256:abc",
+ MakeConfigJson("EvtPump"), "admin", DateTimeOffset.UtcNow));
+ ExpectMsg(TimeSpan.FromSeconds(5));
+ await Task.Delay(1000);
+
+ actor.Tell(new DisableInstanceCommand("cmd-de1", "EvtPump", DateTimeOffset.UtcNow));
+ Assert.True(ExpectMsg(TimeSpan.FromSeconds(5)).Success);
+ await Task.Delay(300);
+
+ actor.Tell(new EnableInstanceCommand("cmd-en1", "EvtPump", DateTimeOffset.UtcNow));
+ Assert.True(ExpectMsg(TimeSpan.FromSeconds(5)).Success);
+ await Task.Delay(300);
+
+ actor.Tell(new DeleteInstanceCommand("cmd-del-evt", "EvtPump", DateTimeOffset.UtcNow));
+ Assert.True(ExpectMsg(TimeSpan.FromSeconds(5)).Success);
+
+ AwaitAssert(() =>
+ {
+ var rows = siteLog.OfType("deployment");
+ Assert.Contains(rows, r => r.Message.Contains("disabled", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(rows, r => r.Message.Contains("enabled", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(rows, r => r.Message.Contains("deleted", StringComparison.OrdinalIgnoreCase));
+ }, TimeSpan.FromSeconds(2));
+ }
+
[Fact]
public async Task DeploymentManager_Lifecycle_DisableEnableDelete()
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
index b83ca94c..7ea11242 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/InstanceActorTests.cs
@@ -10,6 +10,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
+using ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.TestSupport;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors;
@@ -58,6 +59,82 @@ public class InstanceActorTests : TestKit, IDisposable
try { File.Delete(_dbFile); } catch { /* cleanup */ }
}
+ // ── M1.6: site event log `instance_lifecycle` category ──────────────────
+
+ [Fact]
+ public void InstanceActor_Start_EmitsInstanceLifecycleSiteEvent()
+ {
+ var siteLog = new FakeSiteEventLogger();
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "LifecyclePump",
+ Attributes = [new ResolvedAttribute { CanonicalName = "T", Value = "1", DataType = "Int32" }]
+ };
+
+ ActorOf(Props.Create(() => new InstanceActor(
+ "LifecyclePump",
+ JsonSerializer.Serialize(config),
+ _storage,
+ _compilationService,
+ _sharedScriptLibrary,
+ null,
+ _options,
+ NullLogger.Instance,
+ null,
+ null,
+ new SingleServiceProvider(siteLog))));
+
+ AwaitAssert(() =>
+ {
+ var rows = siteLog.OfType("instance_lifecycle");
+ Assert.Contains(rows, r =>
+ r.Severity == "Info" &&
+ r.InstanceId == "LifecyclePump" &&
+ r.Source == "InstanceActor:LifecyclePump" &&
+ r.Message.Contains("started", StringComparison.OrdinalIgnoreCase));
+ }, TimeSpan.FromSeconds(2));
+ }
+
+ [Fact]
+ public void InstanceActor_Stop_EmitsInstanceLifecycleSiteEvent()
+ {
+ var siteLog = new FakeSiteEventLogger();
+ var config = new FlattenedConfiguration
+ {
+ InstanceUniqueName = "StoppedPump",
+ Attributes = [new ResolvedAttribute { CanonicalName = "T", Value = "1", DataType = "Int32" }]
+ };
+
+ var actor = ActorOf(Props.Create(() => new InstanceActor(
+ "StoppedPump",
+ JsonSerializer.Serialize(config),
+ _storage,
+ _compilationService,
+ _sharedScriptLibrary,
+ null,
+ _options,
+ NullLogger.Instance,
+ null,
+ null,
+ new SingleServiceProvider(siteLog))));
+
+ // Let PreStart land its started event, then stop the actor.
+ AwaitAssert(() => Assert.NotEmpty(siteLog.OfType("instance_lifecycle")),
+ TimeSpan.FromSeconds(2));
+ Watch(actor);
+ actor.Tell(PoisonPill.Instance);
+ ExpectTerminated(actor, TimeSpan.FromSeconds(5));
+
+ AwaitAssert(() =>
+ {
+ var rows = siteLog.OfType("instance_lifecycle");
+ Assert.Contains(rows, r =>
+ r.Severity == "Info" &&
+ r.InstanceId == "StoppedPump" &&
+ r.Message.Contains("stopped", StringComparison.OrdinalIgnoreCase));
+ }, TimeSpan.FromSeconds(2));
+ }
+
[Fact]
public void InstanceActor_LoadsAttributesFromConfig()
{