feat(siteeventlog): emit deployment + instance_lifecycle events (M1.6)

DeploymentManagerActor now fire-and-forgets a 'deployment' site operational
event on deploy/enable/disable/delete outcomes (Info on success, Error on
failure), source 'DeploymentManagerActor'. The disable/delete events are emitted
from the existing PipeTo continuations (safe: reads only the immutable
_serviceProvider and fire-and-forgets).

InstanceActor now emits an 'instance_lifecycle' Info event in PreStart (started)
and a new PostStop (stopped) — covering start/stop/enable/disable/redeploy/
failover transitions from the instance's own vantage point. Both actors already
hold _serviceProvider; no ctor change.

Resolution is optional and LogEventAsync is fire-and-forget so a logging failure
never affects the deployment pipeline or instance lifecycle.
This commit is contained in:
Joseph Doherty
2026-06-15 12:26:54 -04:00
parent a00e43c4f9
commit 09b9e8f259
4 changed files with 229 additions and 2 deletions
@@ -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<DeploymentManagerActor>.Instance)));
NullLogger<DeploymentManagerActor>.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<DeploymentStatusResponse>(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<DeploymentStatusResponse>(TimeSpan.FromSeconds(5));
await Task.Delay(1000);
actor.Tell(new DisableInstanceCommand("cmd-de1", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)).Success);
await Task.Delay(300);
actor.Tell(new EnableInstanceCommand("cmd-en1", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(TimeSpan.FromSeconds(5)).Success);
await Task.Delay(300);
actor.Tell(new DeleteInstanceCommand("cmd-del-evt", "EvtPump", DateTimeOffset.UtcNow));
Assert.True(ExpectMsg<InstanceLifecycleResponse>(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()
{
@@ -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<InstanceActor>.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<InstanceActor>.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()
{