fix(transport): real stale-instance enumeration in ImportResult (M8 D2, #16) + native-alarm rename-redirect test
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the CURRENT flattened-config revision hash of a deployed instance —
|
||||||
|
/// the same value the deployment pipeline produces and stores in
|
||||||
|
/// <c>DeployedConfigSnapshot.RevisionHash</c>. Transport's bundle importer uses
|
||||||
|
/// this to enumerate stale instances after a template overwrite (#16): an
|
||||||
|
/// instance is stale when its freshly-computed hash no longer matches the hash
|
||||||
|
/// captured at deploy time.
|
||||||
|
/// <para>
|
||||||
|
/// The seam lives in Commons so the Transport component can depend on it without
|
||||||
|
/// referencing the DeploymentManager project (where the flattening pipeline that
|
||||||
|
/// implements it lives). The implementation re-flattens the instance via the
|
||||||
|
/// existing flattening pipeline over the SAME scoped <c>DbContext</c>, so it sees
|
||||||
|
/// any template rows the in-flight import has staged on the change tracker but
|
||||||
|
/// not yet committed.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IStaleInstanceProbe
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes the current flattened-config revision hash for the given instance,
|
||||||
|
/// or <c>null</c> when the hash cannot be computed (e.g. the instance or its
|
||||||
|
/// template chain cannot be resolved). Callers treat a <c>null</c> result as
|
||||||
|
/// "cannot determine staleness" and skip the instance — staleness is
|
||||||
|
/// informational, never a reason to abort an import.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="instanceId">The target instance's surrogate id.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The current revision hash (e.g. <c>sha256:...</c>), or <c>null</c>.</returns>
|
||||||
|
Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
|
|
||||||
@@ -40,6 +41,13 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
|
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
|
||||||
services.AddScoped<DeploymentService>();
|
services.AddScoped<DeploymentService>();
|
||||||
services.AddScoped<ArtifactDeploymentService>();
|
services.AddScoped<ArtifactDeploymentService>();
|
||||||
|
|
||||||
|
// #16 (M8 D2): expose the flattening pipeline's revision-hash computation
|
||||||
|
// to the Transport bundle importer through the Commons IStaleInstanceProbe
|
||||||
|
// seam, so a template overwrite can enumerate the deployed instances whose
|
||||||
|
// flattened config has drifted from their deployed snapshot. Scoped so it
|
||||||
|
// shares the per-request DbContext with the in-flight import.
|
||||||
|
services.AddScoped<IStaleInstanceProbe, StaleInstanceProbe>();
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="IStaleInstanceProbe"/> backed by the deployment flattening pipeline.
|
||||||
|
/// Re-flattens an instance through <see cref="IFlatteningPipeline"/> and returns
|
||||||
|
/// the computed revision hash — the exact same value
|
||||||
|
/// <c>DeploymentService.GetDeploymentComparisonAsync</c> compares against the
|
||||||
|
/// deployed snapshot to decide staleness. Hosted in DeploymentManager so the
|
||||||
|
/// Transport component (which references only Commons + TemplateEngine) can probe
|
||||||
|
/// staleness without taking a DeploymentManager project reference.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaleInstanceProbe : IStaleInstanceProbe
|
||||||
|
{
|
||||||
|
private readonly IFlatteningPipeline _flatteningPipeline;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new <see cref="StaleInstanceProbe"/>.</summary>
|
||||||
|
/// <param name="flatteningPipeline">The deployment flattening pipeline used to recompute the current revision hash.</param>
|
||||||
|
public StaleInstanceProbe(IFlatteningPipeline flatteningPipeline)
|
||||||
|
{
|
||||||
|
_flatteningPipeline = flatteningPipeline ?? throw new ArgumentNullException(nameof(flatteningPipeline));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string?> GetCurrentRevisionHashAsync(int instanceId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// The pipeline returns a Result; a flatten failure (e.g. unresolvable
|
||||||
|
// template chain mid-import) yields null so the caller treats the
|
||||||
|
// instance as "staleness indeterminate" and skips it. Flattening reuses
|
||||||
|
// the scoped ITemplateEngineRepository, so it observes template rows the
|
||||||
|
// in-flight import has staged on the shared change tracker.
|
||||||
|
var result = await _flatteningPipeline
|
||||||
|
.FlattenAndValidateAsync(instanceId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return result.IsSuccess ? result.Value.RevisionHash : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||||
@@ -73,6 +74,11 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
private readonly IOptions<TransportOptions> _options;
|
private readonly IOptions<TransportOptions> _options;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly SemanticValidator _semanticValidator;
|
private readonly SemanticValidator _semanticValidator;
|
||||||
|
// #16 (M8 D2): optional. Implemented in DeploymentManager (where the flattening
|
||||||
|
// pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null
|
||||||
|
// in a host that registers Transport without DeploymentManager — staleness is
|
||||||
|
// then best-effort empty (informational only, never gates the import).
|
||||||
|
private readonly IStaleInstanceProbe? _staleInstanceProbe;
|
||||||
private readonly ILogger<BundleImporter>? _logger;
|
private readonly ILogger<BundleImporter>? _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -95,6 +101,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
|
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
|
||||||
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
|
||||||
/// <param name="semanticValidator">Validates template references before applying.</param>
|
/// <param name="semanticValidator">Validates template references before applying.</param>
|
||||||
|
/// <param name="staleInstanceProbe">Optional (#16): recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
|
||||||
|
/// <param name="logger">Optional logger.</param>
|
||||||
public BundleImporter(
|
public BundleImporter(
|
||||||
BundleSerializer bundleSerializer,
|
BundleSerializer bundleSerializer,
|
||||||
ManifestValidator manifestValidator,
|
ManifestValidator manifestValidator,
|
||||||
@@ -113,6 +121,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
IAuditCorrelationContext correlationContext,
|
IAuditCorrelationContext correlationContext,
|
||||||
ScadaBridgeDbContext dbContext,
|
ScadaBridgeDbContext dbContext,
|
||||||
SemanticValidator semanticValidator,
|
SemanticValidator semanticValidator,
|
||||||
|
IStaleInstanceProbe? staleInstanceProbe = null,
|
||||||
ILogger<BundleImporter>? logger = null)
|
ILogger<BundleImporter>? logger = null)
|
||||||
{
|
{
|
||||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||||
@@ -132,6 +141,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||||
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||||
|
_staleInstanceProbe = staleInstanceProbe;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1057,8 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
|
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
|
||||||
// is the next statement, so a read against the change tracker here sees
|
// is the next statement, so a read against the change tracker here sees
|
||||||
// the full post-apply graph before commit.
|
// the full post-apply graph before commit.
|
||||||
|
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
|
||||||
|
content, resolutionMap, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
await _auditService.LogAsync(
|
await _auditService.LogAsync(
|
||||||
user: user,
|
user: user,
|
||||||
@@ -1089,7 +1101,7 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
Overwritten: summary.Overwritten,
|
Overwritten: summary.Overwritten,
|
||||||
Skipped: summary.Skipped,
|
Skipped: summary.Skipped,
|
||||||
Renamed: summary.Renamed,
|
Renamed: summary.Renamed,
|
||||||
StaleInstanceIds: Array.Empty<int>(),
|
StaleInstanceIds: staleInstanceIds,
|
||||||
AuditEventCorrelation: bundleImportId.ToString(),
|
AuditEventCorrelation: bundleImportId.ToString(),
|
||||||
ApiKeysIgnored: apiKeysIgnored);
|
ApiKeysIgnored: apiKeysIgnored);
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1209,113 @@ public sealed class BundleImporter : IBundleImporter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #16 (M8 D2): enumerates the deployed instances stale-ed by this import.
|
||||||
|
/// Overwriting a template changes the flattened-config hash of every instance
|
||||||
|
/// of that template, so a deployed instance's freshly-computed revision hash
|
||||||
|
/// will no longer match the hash captured in its <c>DeployedConfigSnapshot</c>
|
||||||
|
/// at deploy time — it surfaces as "stale" on the Deployments page. This walks
|
||||||
|
/// the templates THIS import OVERWROTE, finds each one's deployed instances
|
||||||
|
/// (State <see cref="InstanceState.Enabled"/>/<see cref="InstanceState.Disabled"/>
|
||||||
|
/// with a snapshot), recomputes their current hash through
|
||||||
|
/// <see cref="IStaleInstanceProbe"/> (which reads the just-staged template
|
||||||
|
/// rows on the shared change tracker), and collects the ids whose hash drifted.
|
||||||
|
/// <para>
|
||||||
|
/// Freshly-IMPORTED instances are excluded structurally: they enter the target
|
||||||
|
/// <see cref="InstanceState.NotDeployed"/> (no snapshot), so they're "new", not
|
||||||
|
/// "stale". Best-effort: a probe that returns null or throws for one instance
|
||||||
|
/// is skipped (logged), never aborting the import — staleness is informational
|
||||||
|
/// and the import's own success path is authoritative. Returns an empty list
|
||||||
|
/// when no probe is wired (Transport-without-DeploymentManager hosts).
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IReadOnlyList<int>> ComputeStaleInstanceIdsAsync(
|
||||||
|
BundleContentDto content,
|
||||||
|
Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// No flattening pipeline registered → can't recompute hashes. Staleness is
|
||||||
|
// informational, so degrade gracefully to "none".
|
||||||
|
if (_staleInstanceProbe is null || content.Templates.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which templates did THIS import overwrite? A bundle template is
|
||||||
|
// overwritten when its resolution Action == Overwrite AND a target template
|
||||||
|
// of that name already exists (otherwise the Overwrite branch falls through
|
||||||
|
// to Add — see ApplyTemplatesAsync). Map those names to their target ids.
|
||||||
|
var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||||||
|
.GroupBy(t => t.Name, StringComparer.Ordinal)
|
||||||
|
.ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var overwrittenTemplateIds = new HashSet<int>();
|
||||||
|
foreach (var dto in content.Templates)
|
||||||
|
{
|
||||||
|
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||||||
|
if (resolution.Action != ResolutionAction.Overwrite) continue;
|
||||||
|
if (targetTemplateIdByName.TryGetValue(dto.Name, out var templateId))
|
||||||
|
{
|
||||||
|
overwrittenTemplateIds.Add(templateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overwrittenTemplateIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleIds = new List<int>();
|
||||||
|
foreach (var templateId in overwrittenTemplateIds)
|
||||||
|
{
|
||||||
|
var instances = await _templateRepo
|
||||||
|
.GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false);
|
||||||
|
foreach (var instance in instances)
|
||||||
|
{
|
||||||
|
// NotDeployed instances (including everything this import just
|
||||||
|
// created/overwrote) are "new", not "stale" — skip them.
|
||||||
|
if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A deployed instance has a snapshot; its absence means the
|
||||||
|
// instance is not actually deployed (defensive — State already
|
||||||
|
// gated this) so there's no baseline to compare against.
|
||||||
|
var snapshot = await _dbContext.DeployedConfigSnapshots
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.InstanceId == instance.Id)
|
||||||
|
.OrderByDescending(s => s.DeployedAt)
|
||||||
|
.FirstOrDefaultAsync(ct)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (snapshot is null) continue;
|
||||||
|
|
||||||
|
var currentHash = await _staleInstanceProbe
|
||||||
|
.GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false);
|
||||||
|
// null => hash indeterminate; don't claim staleness either way.
|
||||||
|
if (currentHash is null) continue;
|
||||||
|
|
||||||
|
if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
staleIds.Add(instance.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Best-effort: a single instance's hash failure must not abort
|
||||||
|
// the (already-successful) import. Skip it and move on.
|
||||||
|
_logger?.LogWarning(ex,
|
||||||
|
"Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
|
||||||
|
instance.Id, templateId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return staleIds;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
|
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
|
||||||
private sealed class ImportSummary
|
private sealed class ImportSummary
|
||||||
{
|
{
|
||||||
|
|||||||
+245
@@ -2,7 +2,9 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
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.ExternalSystems;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
|
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;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
using ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||||
@@ -64,9 +68,16 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
// M8: DependencyResolver now injects ISiteRepository to walk the
|
// M8: DependencyResolver now injects ISiteRepository to walk the
|
||||||
// site/data-connection/instance closure; register it or activation fails.
|
// site/data-connection/instance closure; register it or activation fails.
|
||||||
services.AddScoped<ISiteRepository, SiteRepository>();
|
services.AddScoped<ISiteRepository, SiteRepository>();
|
||||||
|
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
|
||||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||||
services.AddScoped<IAuditService, AuditService>();
|
services.AddScoped<IAuditService, AuditService>();
|
||||||
services.AddTransport();
|
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();
|
_provider = services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
@@ -926,4 +937,238 @@ public sealed class BundleImporterApplyTests : IDisposable
|
|||||||
Assert.Contains(preview.Items, item =>
|
Assert.Contains(preview.Items, item =>
|
||||||
item.EntityType == "ApiMethod" && item.Name == "GetStatus");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-1
@@ -323,7 +323,8 @@ public sealed class SiteInstanceImportTests : IDisposable
|
|||||||
// Counts: site + connection + instance added (template was Skipped).
|
// Counts: site + connection + instance added (template was Skipped).
|
||||||
Assert.Equal(3, result.Added);
|
Assert.Equal(3, result.Added);
|
||||||
Assert.Equal(1, result.Skipped);
|
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);
|
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
|
// C2 — two sites with same-named connections resolve independently
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user