diff --git a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json index 297e1971..239b3a6e 100644 --- a/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json +++ b/archreview/plans/R2-11-tagconfig-consolidation-plan.md.tasks.json @@ -22,7 +22,7 @@ { "id": "T18", "subject": "FOCAS capability-matrix pre-flight on the equipment-tag _parseRef resolve path (BadNodeIdUnknown on rejection)", "status": "completed", "blockedBy": ["T17"] }, { "id": "T19", "subject": "Modbus probe parses the factory DTO shape (timeoutMs round-trip; OpcUaClient parity rule)", "status": "completed", "blockedBy": [] }, { "id": "T20", "subject": "EquipmentTagConfigInspector DriverType-dispatch map in ControlPlane (+6 Contracts references)", "status": "completed", "blockedBy": ["T12", "T13", "T14", "T15", "T16", "T17"] }, - { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "pending", "blockedBy": ["T20"] }, + { "id": "T21", "subject": "AdminOperationsActor deploy-gate wiring + Deployment:TagConfigValidationMode (Warn default | Error opt-in); actor-level consuming test", "status": "completed", "blockedBy": ["T20"] }, { "id": "T22", "subject": "AdminUI writable checkbox in the six driver-typed tag editors (models + razor; live /run verify at execution)", "status": "pending", "blockedBy": [] }, { "id": "T23", "subject": "Docs + decision record: two-mode rollout, unknown-keys non-goal, FOCAS writable migration note, STATUS.md/00-INDEX.md entries + Phase-C follow-up", "status": "pending", "blockedBy": ["T17", "T21"] }, { "id": "T24", "subject": "Final sweep: zero parity-comment/ReadEnum-copy grep hits; whole-solution build + test gate", "status": "pending", "blockedBy": ["T5", "T6", "T7", "T8", "T9", "T10", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T21", "T22", "T23"] } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index 7448ed8d..645ee64e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -25,31 +25,37 @@ public sealed class AdminOperationsActor : ReceiveActor private readonly IDbContextFactory _dbFactory; private readonly IActorRef _coordinator; private readonly IReadOnlyDictionary _probesByType; + private readonly TagConfigValidationMode _tagConfigValidationMode; private readonly ILoggingAdapter _log = Context.GetLogger(); /// Creates actor props for the admin operations actor. /// Factory for creating config database contexts. /// Reference to the deployment coordinator actor. /// Driver probes registered in DI; keyed by DriverType (case-insensitive). + /// Deploy-gate TagConfig strictness mode (Warn default | Error opt-in). /// Props configured to create an AdminOperationsActor. public static Props Props( IDbContextFactory dbFactory, IActorRef coordinator, - IEnumerable probes) => - Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes)); + IEnumerable probes, + TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn) => + Akka.Actor.Props.Create(() => new AdminOperationsActor(dbFactory, coordinator, probes, tagConfigValidationMode)); /// Initializes a new instance of the AdminOperationsActor. /// Factory for creating config database contexts. /// Reference to the deployment coordinator actor. /// Driver probes registered in DI; keyed by DriverType (case-insensitive). + /// Deploy-gate TagConfig strictness mode (Warn default | Error opt-in). public AdminOperationsActor( IDbContextFactory dbFactory, IActorRef coordinator, - IEnumerable probes) + IEnumerable probes, + TagConfigValidationMode tagConfigValidationMode = TagConfigValidationMode.Warn) { _dbFactory = dbFactory; _coordinator = coordinator; _probesByType = probes.ToDictionary(p => p.DriverType, StringComparer.OrdinalIgnoreCase); + _tagConfigValidationMode = tagConfigValidationMode; ReceiveAsync(HandleStartDeploymentAsync); ReceiveAsync(HandleTestDriverConnectAsync); @@ -202,6 +208,31 @@ public sealed class AdminOperationsActor : ReceiveActor errors.AddRange(DraftValidator.ValidateClusterTopology(cluster, nodes)); } + // R2-11 (05/CONV-2): deploy-time TagConfig strictness inspection over equipment tags. The + // driver-typed EquipmentTagConfigInspector surfaces present-but-invalid enum values (which the + // lenient runtime silently defaults) + structurally-unparseable TagConfig. In Warn mode + // (default) these are non-blocking (logged + appended to the accepted result Message); in Error + // mode they fold into the reject list so a typo'd config cannot be re-deployed until fixed. + // Running servers are untouched either way — the gate only sees re-deploys. + var driverTypeById = draft.DriverInstances + .GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal); + var tagConfigWarnings = new List<(string TagId, string Text)>(); + foreach (var t in draft.Tags.Where(t => t.EquipmentId is not null)) + { + if (!driverTypeById.TryGetValue(t.DriverInstanceId, out var driverType)) continue; + foreach (var w in EquipmentTagConfigInspector.Inspect(driverType, t.TagConfig)) + tagConfigWarnings.Add((t.TagId, $"tag '{t.TagId}' (equipment '{t.EquipmentId}'): {w}")); + } + if (tagConfigWarnings.Count > 0) + { + if (_tagConfigValidationMode == TagConfigValidationMode.Error) + errors.AddRange(tagConfigWarnings.Select(x => new ValidationError("TagConfigInvalid", x.Text, x.TagId))); + else + foreach (var x in tagConfigWarnings) + _log.Warning("StartDeployment: TagConfig warning (Warn mode, non-blocking): {Warning}", x.Text); + } + if (errors.Count > 0) { var summary = string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")); @@ -277,11 +308,18 @@ public sealed class AdminOperationsActor : ReceiveActor _coordinator.Tell(new DispatchDeployment(deploymentId, revHash, msg.CorrelationId)); + // Accepted message = the compile-cost advisory (if any) + any Warn-mode TagConfig warnings, so + // the operator sees the non-blocking strictness findings on a successful deploy. + var messageParts = new List(); + if (advisory is not null) messageParts.Add(advisory); + messageParts.AddRange(tagConfigWarnings.Select(x => x.Text)); + var acceptedMessage = messageParts.Count > 0 ? string.Join("; ", messageParts) : null; + replyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.Accepted, deploymentId, revHash, - Message: advisory, + Message: acceptedMessage, msg.CorrelationId)); } catch (Exception ex) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs new file mode 100644 index 00000000..e53e5924 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/TagConfigValidationMode.cs @@ -0,0 +1,18 @@ +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; + +/// +/// How the deploy gate treats TagConfig strictness inspection results (R2-11, 05/CONV-2). Sourced from +/// Deployment:TagConfigValidationMode in appsettings; defaults to so existing +/// deployments are never blocked by a previously-tolerated typo. +/// +public enum TagConfigValidationMode +{ + /// Non-blocking: inspection warnings are logged and appended to the accepted + /// StartDeploymentResult.Message, but the deploy still succeeds. The default. + Warn, + + /// Blocking: inspection warnings fold into the draft-gate reject list, so a config with a + /// typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment + /// once the fleet deploys warning-clean. + Error, +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs index e9276742..20753d13 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using Akka.Actor; using Akka.Cluster.Hosting; using Akka.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; @@ -61,7 +62,12 @@ public static class ServiceCollectionExtensions var dbFactory = resolver.GetService>(); var coordinator = registry.Get(); var probes = resolver.GetService>() ?? Enumerable.Empty(); - return AdminOperationsActor.Props(dbFactory, coordinator, probes); + // R2-11 (05/CONV-2): deploy-gate TagConfig strictness mode from appsettings + // (Deployment:TagConfigValidationMode = Warn default | Error opt-in). + var mode = Enum.TryParse( + resolver.GetService()?["Deployment:TagConfigValidationMode"], + ignoreCase: true, out var m) ? m : TagConfigValidationMode.Warn; + return AdminOperationsActor.Props(dbFactory, coordinator, probes, mode); }, singletonOptions); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs new file mode 100644 index 00000000..a501ef29 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorTagConfigGateTests.cs @@ -0,0 +1,131 @@ +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +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; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +/// +/// R2-11 (05/CONV-2) — the deploy gate's TagConfig strictness inspection, exercised THROUGH the actor +/// (the F10b/PR#423 lesson: an inspector built but never called is inert). Warn mode (default) accepts +/// the deploy with the warning surfaced in the result Message; Error mode rejects the same draft; a +/// clean config and an unmapped (Galaxy) tag produce no TagConfig noise. +/// +public sealed class AdminOperationsActorTagConfigGateTests : ControlPlaneActorTestBase +{ + private static void Seed(IDbContextFactory dbFactory, string driverType, string tagConfig, + string tagName = "flow") + { + var uuid = Guid.NewGuid(); + var equipmentId = DraftValidator.DeriveEquipmentId(uuid); + using var db = dbFactory.CreateDbContext(); + db.Namespaces.Add(new Namespace + { + NamespaceId = "ns-1", ClusterId = "", Kind = NamespaceKind.Equipment, NamespaceUri = "urn:eq", + }); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "d-1", ClusterId = "", NamespaceId = "ns-1", + Name = "drv", DriverType = driverType, DriverConfig = "{}", + }); + db.Equipment.Add(new Equipment + { + EquipmentUuid = uuid, EquipmentId = equipmentId, Name = "pump-01", + DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "PUMP01", + }); + db.Tags.Add(new Tag + { + TagId = "tag-1", DriverInstanceId = "d-1", EquipmentId = equipmentId, + Name = tagName, DataType = "Int16", AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig, + }); + db.SaveChanges(); + } + + private const string TypoModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}"; + private const string CleanModbus = "{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Int16\"}"; + + [Fact] + public void Warn_mode_accepts_and_surfaces_the_warning_in_the_message() + { + var dbFactory = NewInMemoryDbFactory(); + Seed(dbFactory, "Modbus", TypoModbus); + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props( + dbFactory, coordinator.Ref, Enumerable.Empty(), TagConfigValidationMode.Warn)); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldContain("Intt16"); + reply.Message.ShouldContain("tag-1"); + } + + [Fact] + public void Error_mode_rejects_the_same_draft() + { + var dbFactory = NewInMemoryDbFactory(); + Seed(dbFactory, "Modbus", TypoModbus); + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props( + dbFactory, coordinator.Ref, Enumerable.Empty(), TagConfigValidationMode.Error)); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectNoMsg(TimeSpan.FromMilliseconds(500)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldContain("TagConfigInvalid"); + reply.Message.ShouldContain("Intt16"); + + using var verify = dbFactory.CreateDbContext(); + verify.Deployments.Count().ShouldBe(0); + } + + [Fact] + public void Clean_config_accepts_with_no_tagconfig_noise() + { + var dbFactory = NewInMemoryDbFactory(); + Seed(dbFactory, "Modbus", CleanModbus); + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props( + dbFactory, coordinator.Ref, Enumerable.Empty(), TagConfigValidationMode.Warn)); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + var reply = ExpectMsg(TimeSpan.FromSeconds(3)); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + (reply.Message is null || !reply.Message.Contains("Intt16")).ShouldBeTrue(); + } + + [Fact] + public void Unmapped_galaxy_tag_is_skipped_even_in_error_mode() + { + var dbFactory = NewInMemoryDbFactory(); + // Galaxy tag carrying a FullName (passes the DraftValidator Galaxy rule); the inspector has no + // Galaxy mapping, so it is skipped regardless of mode. + Seed(dbFactory, "GalaxyMxGateway", "{\"FullName\":\"Obj.Attr\",\"dataType\":\"nonsense\"}"); + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props( + dbFactory, coordinator.Ref, Enumerable.Empty(), TagConfigValidationMode.Error)); + + actor.Tell(new StartDeployment("joe", CorrelationId.NewId())); + + coordinator.ExpectMsg(TimeSpan.FromSeconds(3)); + ExpectMsg(TimeSpan.FromSeconds(3)).Outcome.ShouldBe(StartDeploymentOutcome.Accepted); + } +}