From 09b9e8f25977d0bc17f5afe132cde7c2c03a084c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 15 Jun 2026 12:26:54 -0400 Subject: [PATCH] feat(siteeventlog): emit deployment + instance_lifecycle events (M1.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Actors/DeploymentManagerActor.cs | 52 +++++++++++++ .../Actors/InstanceActor.cs | 30 ++++++++ .../Actors/DeploymentManagerActorTests.cs | 72 ++++++++++++++++- .../Actors/InstanceActorTests.cs | 77 +++++++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) 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() {