using Akka.Actor; using Akka.Event; using Microsoft.EntityFrameworkCore; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy; using ZB.MOM.WW.OtOpcUa.Commons.Types; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Configuration.Entities; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; /// /// Cluster-singleton admin operations actor. Owns the "snapshot the live-edit state and start /// a deployment" workflow plus (eventually) all mutating live-edit ops invoked by the admin UI. /// Routed to via from anywhere in the cluster. /// public sealed class AdminOperationsActor : ReceiveActor { private readonly IDbContextFactory _dbFactory; private readonly IActorRef _coordinator; private readonly ILoggingAdapter _log = Context.GetLogger(); public static Props Props( IDbContextFactory dbFactory, IActorRef coordinator) => Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator)); public AdminOperationsActor( IDbContextFactory dbFactory, IActorRef coordinator) { _dbFactory = dbFactory; _coordinator = coordinator; ReceiveAsync(HandleStartDeploymentAsync); } private async Task HandleStartDeploymentAsync(StartDeployment msg) { var replyTo = Sender; try { await using var db = await _dbFactory.CreateDbContextAsync(); // Refuse if any deployment is already in flight — keeps the coordinator's state // unambiguous. The UI is expected to wait for the in-flight one to seal or fail. var inflight = await db.Deployments .Where(d => d.Status == DeploymentStatus.Dispatching || d.Status == DeploymentStatus.AwaitingApplyAcks) .Select(d => d.DeploymentId) .FirstOrDefaultAsync(); if (inflight != Guid.Empty) { replyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.AnotherDeploymentInFlight, DeploymentId: new DeploymentId(inflight), RevisionHash: null, Message: $"Deployment {inflight:N} is still in flight.", msg.CorrelationId)); return; } var artifact = await ConfigComposer.SnapshotAndFlattenAsync(db); var deploymentId = DeploymentId.NewId(); var revHash = RevisionHash.Parse(artifact.RevisionHash); db.Deployments.Add(new Deployment { DeploymentId = deploymentId.Value, RevisionHash = artifact.RevisionHash, Status = DeploymentStatus.Dispatching, CreatedBy = msg.CreatedBy, ArtifactBlob = artifact.Blob, }); // Marker ConfigEdit row so the audit timeline shows the deployment snapshot. db.ConfigEdits.Add(new ConfigEdit { EntityType = "Deployment", EntityId = deploymentId.Value, FieldsJson = $"{{\"revisionHash\":\"{artifact.RevisionHash}\",\"sizeBytes\":{artifact.Blob.Length}}}", EditedBy = msg.CreatedBy, SourceNode = Akka.Cluster.Cluster.Get(Context.System).SelfAddress.Host ?? "unknown", }); await db.SaveChangesAsync(); _coordinator.Tell(new DispatchDeployment(deploymentId, revHash, msg.CorrelationId)); replyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.Accepted, deploymentId, revHash, Message: null, msg.CorrelationId)); } catch (Exception ex) { _log.Error(ex, "StartDeployment failed for {CreatedBy}", msg.CreatedBy); replyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.Rejected, DeploymentId: null, RevisionHash: null, Message: ex.Message, msg.CorrelationId)); } } }