fix(transport): real stale-instance enumeration in ImportResult (M8 D2, #16) + native-alarm rename-redirect test
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
@@ -73,6 +74,11 @@ public sealed class BundleImporter : IBundleImporter
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
@@ -95,6 +101,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
/// <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="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(
|
||||
BundleSerializer bundleSerializer,
|
||||
ManifestValidator manifestValidator,
|
||||
@@ -113,6 +121,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
IAuditCorrelationContext correlationContext,
|
||||
ScadaBridgeDbContext dbContext,
|
||||
SemanticValidator semanticValidator,
|
||||
IStaleInstanceProbe? staleInstanceProbe = null,
|
||||
ILogger<BundleImporter>? logger = null)
|
||||
{
|
||||
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
|
||||
@@ -132,6 +141,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
|
||||
_staleInstanceProbe = staleInstanceProbe;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -1047,6 +1057,8 @@ public sealed class BundleImporter : IBundleImporter
|
||||
// Array.Empty<int>() placeholder). The single deferred SaveChangesAsync
|
||||
// is the next statement, so a read against the change tracker here sees
|
||||
// the full post-apply graph before commit.
|
||||
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
|
||||
content, resolutionMap, ct).ConfigureAwait(false);
|
||||
|
||||
await _auditService.LogAsync(
|
||||
user: user,
|
||||
@@ -1089,7 +1101,7 @@ public sealed class BundleImporter : IBundleImporter
|
||||
Overwritten: summary.Overwritten,
|
||||
Skipped: summary.Skipped,
|
||||
Renamed: summary.Renamed,
|
||||
StaleInstanceIds: Array.Empty<int>(),
|
||||
StaleInstanceIds: staleInstanceIds,
|
||||
AuditEventCorrelation: bundleImportId.ToString(),
|
||||
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>
|
||||
private sealed class ImportSummary
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user