fix(transport): real stale-instance enumeration in ImportResult (M8 D2, #16) + native-alarm rename-redirect test

This commit is contained in:
Joseph Doherty
2026-06-18 07:35:08 -04:00
parent c8211f6363
commit d45a7a5760
6 changed files with 570 additions and 2 deletions
@@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -13,6 +15,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
@@ -64,9 +68,16 @@ public sealed class BundleImporterApplyTests : IDisposable
// M8: DependencyResolver now injects ISiteRepository to walk the
// site/data-connection/instance closure; register it or activation fails.
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
// #16 (M8 D2): the stale-instance probe is implemented in DeploymentManager
// and the flatten/hash primitives in TemplateEngine — register both so
// BundleImporter resolves a real IStaleInstanceProbe and ApplyAsync can
// compute StaleInstanceIds against genuine revision hashes.
services.AddTemplateEngine();
services.AddDeploymentManager();
_provider = services.BuildServiceProvider();
}
@@ -926,4 +937,238 @@ public sealed class BundleImporterApplyTests : IDisposable
Assert.Contains(preview.Items, item =>
item.EntityType == "ApiMethod" && item.Name == "GetStatus");
}
// ─────────────────────────────────────────────────────────────────────
// #16 (M8 D2): StaleInstanceIds enumeration. Overwriting a template changes
// its flattened-config hash, so deployed instances of that template drift
// from their DeployedConfigSnapshot.RevisionHash and must surface as stale
// in the import result. NotDeployed (incl. freshly-imported) instances are
// never stale.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Seeds an Enabled instance of <paramref name="templateName"/> and a
/// <see cref="DeployedConfigSnapshot"/> whose RevisionHash is the GENUINE hash
/// the deployment flattening pipeline computes for the instance's CURRENT
/// (pre-import) config. Returns the instance id. Mirrors how the deploy path
/// captures a snapshot so the staleness comparison is faithful.
/// </summary>
private async Task<int> SeedDeployedInstanceWithRealSnapshotAsync(
string templateName, string instanceName)
{
int instanceId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = await ctx.Templates.SingleAsync(t => t.Name == templateName);
var instance = new Instance(instanceName)
{
TemplateId = template.Id,
SiteId = (await ctx.Sites.FirstAsync()).Id,
State = InstanceState.Enabled,
};
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instanceId = instance.Id;
// Compute the REAL current revision hash via the same pipeline the
// deploy path uses, then persist it as the deployed snapshot so the
// import's drift comparison runs against a genuine baseline.
var pipeline = scope.ServiceProvider.GetRequiredService<IFlatteningPipeline>();
var flattened = await pipeline.FlattenAndValidateAsync(instanceId);
Assert.True(flattened.IsSuccess,
$"Seed flatten failed: {(flattened.IsFailure ? flattened.Error : "(success)")}");
ctx.DeployedConfigSnapshots.Add(new DeployedConfigSnapshot(
deploymentId: Guid.NewGuid().ToString(),
revisionHash: flattened.Value.RevisionHash,
configurationJson: System.Text.Json.JsonSerializer.Serialize(flattened.Value.Configuration))
{
InstanceId = instanceId,
DeployedAt = DateTimeOffset.UtcNow,
});
await ctx.SaveChangesAsync();
}
return instanceId;
}
private async Task<int> SeedSiteAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = new ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites.Site("Plant 1", "plant-1")
{
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
return site.Id;
}
[Fact]
public async Task ApplyAsync_overwriting_template_marks_deployed_instance_stale()
{
// Arrange — seed Pump with the BUNDLE's (new) attribute shape, export it,
// then mutate the target template + reseed the deployed snapshot to the
// OLD shape so an Overwrite restores the new shape and drifts the deployed
// instance off its captured hash.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Pump") { Description = "v2" };
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
t.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" }); // extra attr in bundle
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target template to the OLD shape (drop Pressure, change Flow),
// then deploy an instance capturing the OLD config's hash.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
t.Description = "v1";
var pressure = t.Attributes.Single(a => a.Name == "Pressure");
t.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
await ctx.SaveChangesAsync();
}
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
// Act — Overwrite Pump back to the bundle's v2 shape.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — the deployed instance drifted off its snapshot hash and is stale.
Assert.Equal(1, result.Overwritten);
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
}
[Fact]
public async Task ApplyAsync_overwriting_template_does_not_mark_unchanged_template_instance_stale()
{
// Two templates: Pump (overwritten with a CHANGED shape) and Valve
// (overwritten with an IDENTICAL shape — its instance must NOT be stale).
// The export carries both at their seeded shape; we mutate only Pump in
// the target so its overwrite changes the hash, while Valve's overwrite
// is a no-op hash-wise.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = new Template("Pump") { Description = "v2" };
pump.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
pump.Attributes.Add(new TemplateAttribute("Pressure") { DataType = DataType.Float, Value = "2.0" });
ctx.Templates.Add(pump);
var valve = new Template("Valve") { Description = "stable" };
valve.Attributes.Add(new TemplateAttribute("Position") { DataType = DataType.Float, Value = "0.5" });
ctx.Templates.Add(valve);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate ONLY Pump in the target so its overwrite changes the hash; leave
// Valve identical to the bundle so its overwrite is hash-neutral.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var pump = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
var pressure = pump.Attributes.Single(a => a.Name == "Pressure");
pump.Attributes.Remove(pressure);
ctx.TemplateAttributes.Remove(pressure);
await ctx.SaveChangesAsync();
}
var pumpInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
var valveInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Valve", "Valve-Deployed");
// Act — Overwrite BOTH templates.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Overwrite, null),
new("Template", "Valve", ResolutionAction.Overwrite, null),
},
user: "bob");
}
// Assert — only Pump's deployed instance is stale; Valve's is not.
Assert.Contains(pumpInstanceId, result.StaleInstanceIds);
Assert.DoesNotContain(valveInstanceId, result.StaleInstanceIds);
}
[Fact]
public async Task ApplyAsync_freshly_imported_NotDeployed_instance_is_never_stale()
{
// A bundle that carries a template AND an instance of it: the imported
// instance enters the target NotDeployed (no snapshot), so even though the
// template is "new" to the target it must NOT appear in StaleInstanceIds.
// We also overwrite an EXISTING deployed instance's template to prove the
// stale list contains the deployed one but never the fresh one.
await SeedSiteAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Pump") { Description = "v2" };
t.Attributes.Add(new TemplateAttribute("Flow") { DataType = DataType.Float, Value = "1.0" });
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportAndLoadAsync();
// Mutate the target template, then deploy an instance capturing the OLD hash.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.Include(x => x.Attributes).SingleAsync(x => x.Name == "Pump");
t.Attributes.Single(a => a.Name == "Flow").Value = "0.0";
await ctx.SaveChangesAsync();
}
var deployedInstanceId = await SeedDeployedInstanceWithRealSnapshotAsync("Pump", "Pump-Deployed");
// Also seed a NotDeployed instance of the same template (simulates a
// never-deployed / freshly-imported instance) — it has no snapshot.
int notDeployedInstanceId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = await ctx.Templates.SingleAsync(x => x.Name == "Pump");
var fresh = new Instance("Pump-Fresh")
{
TemplateId = t.Id,
SiteId = (await ctx.Sites.FirstAsync()).Id,
State = InstanceState.NotDeployed,
};
ctx.Instances.Add(fresh);
await ctx.SaveChangesAsync();
notDeployedInstanceId = fresh.Id;
}
// Act — Overwrite Pump.
ImportResult result;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
result = await importer.ApplyAsync(sessionId,
new List<ImportResolution> { new("Template", "Pump", ResolutionAction.Overwrite, null) },
user: "bob");
}
// Assert — deployed instance is stale; the NotDeployed one is excluded.
Assert.Contains(deployedInstanceId, result.StaleInstanceIds);
Assert.DoesNotContain(notDeployedInstanceId, result.StaleInstanceIds);
}
}
@@ -323,7 +323,8 @@ public sealed class SiteInstanceImportTests : IDisposable
// Counts: site + connection + instance added (template was Skipped).
Assert.Equal(3, result.Added);
Assert.Equal(1, result.Skipped);
// D2 has not run yet — StaleInstanceIds stays empty.
// No template was overwritten and the imported instance is NotDeployed, so
// the D2 (#16) stale-instance enumeration finds nothing.
Assert.Empty(result.StaleInstanceIds);
}
@@ -866,6 +867,131 @@ public sealed class SiteInstanceImportTests : IDisposable
}
}
// ──────────────────────────────────────────────────────────────────────
// M-4 — native-alarm-source ConnectionNameOverride RENAME-REDIRECT
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_native_alarm_override_connection_name_rewritten_to_redirected_target_name()
{
// M-4 regression. The existing tests only cover the same-name identity case
// (source connection name == target connection name), so a bug that fails to
// rewrite ConnectionNameOverride on a RENAME-REDIRECT would go unnoticed.
// Here the operator maps the source connection "SourceConn" to a
// DIFFERENTLY-named existing target connection "TargetConn" via an explicit
// MapToExisting. After import, the imported instance's native-alarm-source
// override (and its connection binding) must carry the TARGET name, not the
// source's.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
var site = new Site("Plant 1", "plant-1");
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
// The target connection is named DIFFERENTLY from the source's.
var conn = new DataConnection("TargetConn", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
targetConnId = conn.Id;
}
// Hand-pack a bundle whose instance references the SOURCE connection name
// "SourceConn" (both in its binding and its native-alarm override). The
// bundle does NOT carry the connection — the redirect resolves it against
// the existing target "TargetConn".
var content = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiMethods: Array.Empty<ApiMethodDto>())
{
Sites = new[]
{
new SiteDto("plant-1", "Plant 1", null, null, null, null, null),
},
DataConnections = Array.Empty<DataConnectionDto>(),
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-01",
TemplateName: "Pump",
SiteIdentifier: "plant-1",
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: new[]
{
new InstanceNativeAlarmSourceOverrideDto(
SourceCanonicalName: "NativeSrc",
ConnectionNameOverride: "SourceConn",
SourceReferenceOverride: "ns=3;s=Pump.Alarm",
ConditionFilterOverride: null),
},
ConnectionBindings: new[]
{
new InstanceConnectionBindingDto(
AttributeName: "Flow",
ConnectionName: "SourceConn",
DataSourceReferenceOverride: "ns=3;s=Pump.Flow"),
}),
},
};
var sessionId = await PackAndLoadAsync(content);
// Explicit redirect: source "SourceConn" → target "TargetConn" (DIFFERENT name).
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[]
{
new ConnectionMapping("plant-1", "SourceConn", MappingAction.MapToExisting, "TargetConn"),
});
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No new connection created — the redirect resolved the existing target.
var conn = Assert.Single(await ctx.DataConnections.ToListAsync());
Assert.Equal("TargetConn", conn.Name);
Assert.Equal(targetConnId, conn.Id);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// THE FIX: the native-alarm-source override carries the TARGET name,
// not the source's "SourceConn".
var native = Assert.Single(inst.NativeAlarmSourceOverrides);
Assert.Equal("TargetConn", native.ConnectionNameOverride);
// The connection binding FK rewires to the redirected target connection.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(targetConnId, binding.DataConnectionId);
}
Assert.Equal(1, result.Added);
}
// ──────────────────────────────────────────────────────────────────────
// C2 — two sites with same-named connections resolve independently
// ──────────────────────────────────────────────────────────────────────