feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18)

This commit is contained in:
Joseph Doherty
2026-06-18 06:52:40 -04:00
parent 542a3e92eb
commit 6457f03fae
3 changed files with 1524 additions and 2 deletions
@@ -28,10 +28,22 @@ public interface IBundleImporter
/// <param name="resolutions">Per-artifact conflict resolutions from the preview step.</param>
/// <param name="user">Username of the operator performing the import, stamped in audit rows.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="nameMap">
/// The operator-supplied resolution of every source-environment site and connection
/// name the bundle references (M8 D1). Each entry is either
/// <see cref="MappingAction.MapToExisting"/> (bind to an existing target site/connection)
/// or <see cref="MappingAction.CreateNew"/> (create one from the bundle payload). A
/// site or connection that the bundle references but that has no explicit entry here is
/// auto-matched against the target by identity (existing → MapToExisting, otherwise
/// CreateNew). Trailing optional parameter — null is normalised to
/// <see cref="BundleNameMap.Empty"/> so callers that carry no site/instance payload
/// (e.g. central-config-only bundles) keep working unchanged.
/// </param>
/// <returns>A task that resolves to the result of the committed import transaction.</returns>
Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
CancellationToken ct = default);
CancellationToken ct = default,
BundleNameMap? nameMap = null);
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
@@ -863,11 +864,17 @@ public sealed class BundleImporter : IBundleImporter
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
CancellationToken ct = default)
CancellationToken ct = default,
BundleNameMap? nameMap = null)
{
ArgumentNullException.ThrowIfNull(resolutions);
ArgumentNullException.ThrowIfNull(user);
// Normalise null → Empty so the resolve-or-create logic always has a map
// to consult. An empty map means "no explicit operator mappings" — every
// referenced site/connection falls through to the identity auto-match.
nameMap ??= BundleNameMap.Empty;
var session = _sessionStore.Get(sessionId)
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
if (session.Locked)
@@ -929,11 +936,42 @@ public sealed class BundleImporter : IBundleImporter
// a Skip on a dependency surfaces as a missing-reference error
// rather than silently passing.
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
// M8: validate every site / connection / template reference the
// site-instance payload depends on BEFORE any row is staged. Running
// this in the validation phase (not as an apply-pass guard) preserves
// the rollback contract: a structurally-unresolvable bundle fails with
// an empty change tracker, so nothing is half-written on the in-memory
// provider (where the intermediate site/connection flush can't be
// undone by ChangeTracker.Clear). The apply passes keep defensive
// guards, but in normal operation those never fire.
validationErrors = validationErrors.Count > 0
? validationErrors
: await ValidateSiteInstanceReferencesAsync(content, resolutionMap, nameMap, ct).ConfigureAwait(false);
if (validationErrors.Count > 0)
{
throw new SemanticValidationException(validationErrors);
}
// ---- M8 site/instance-scoped apply: sites + connections FIRST ----
// Sites are the FK target for both data connections (SiteId) and
// instances (SiteId); data connections are the FK target for instance
// connection bindings (DataConnectionId) and the rewrite target for
// native-alarm-source ConnectionNameOverride. Resolve-or-create both
// BEFORE the central-config apply so the maps the instance pass needs
// are fully populated, then flush so newly-created site/connection ids
// materialise on the relational provider. (The instance pass itself
// runs AFTER the central-config flush so it can also resolve template
// ids by name.)
var siteBySourceIdentifier = await ApplySitesAsync(
content, nameMap, resolutionMap, user, summary, ct).ConfigureAwait(false);
var connectionMaps = await ApplyDataConnectionsAsync(
content, nameMap, siteBySourceIdentifier, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Flush so site + connection surrogate ids are assigned (relational
// provider) before the instance pass wires its FKs. In-memory assigns
// ids on AddAsync, so this is mostly a no-op there, but it keeps the
// ordering correct on a real DB. Rides the same outer transaction.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await ApplyTemplateFoldersAsync(content.TemplateFolders, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyTemplatesAsync(content.Templates, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySharedScriptsAsync(content.SharedScripts, resolutionMap, user, summary, ct).ConfigureAwait(false);
@@ -964,6 +1002,31 @@ public sealed class BundleImporter : IBundleImporter
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
// ---- M8 site/instance-scoped apply: instances LAST ----
// The instance pass needs every FK target materialised first:
// • template id — resolved by name against the just-flushed central
// config (Templates were flushed above for the alarm/composition
// rewire), plus pre-existing target templates;
// • site id — from the site map built by ApplySitesAsync;
// • connection id — from the connection map built by
// ApplyDataConnectionsAsync.
// It writes the instance row + its four child collections (attribute /
// alarm / native-alarm-source overrides + connection bindings) and
// rewires every name-keyed FK to the target environment's ids.
await ApplyInstancesAsync(
content, resolutionMap, siteBySourceIdentifier, connectionMaps,
user, summary, ct).ConfigureAwait(false);
// ---- D2 pre-commit point (#16): stale-instance computation ----
// Everything the import will write is staged on the change tracker at
// this point but NOT yet committed. D2 computes StaleInstanceIds here
// — target instances whose template was overwritten by this import and
// therefore now carry a stale flattened-config revision — and threads
// the resulting list into the ImportResult below (replacing the
// 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.
await _auditService.LogAsync(
user: user,
action: "BundleImported",
@@ -2387,6 +2450,784 @@ public sealed class BundleImporter : IBundleImporter
};
}
// ─────────────────────────────────────────────────────────────────────
// M8 D1 — site/instance-scoped apply.
//
// Three passes (sites → connections → instances) resolve every cross-
// environment name reference against the TARGET database's own surrogate
// keys, honouring the operator-supplied BundleNameMap with an identity
// auto-match fallback for references the map doesn't mention. Sites and
// connections run before the central-config apply (they are FK targets);
// instances run after the central-config flush (they additionally need
// template ids by name). All three ride the single outer transaction begun
// in ApplyAsync, so a throw anywhere rolls back the whole import.
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// Resolve-or-create every target <see cref="Site"/> the bundle references,
/// returning a <c>sourceSiteIdentifier → target Site</c> map (each value
/// carries the target environment's surrogate <see cref="Site.Id"/>).
/// <para>
/// A site's mapping is taken from <paramref name="nameMap"/> (matched by
/// <see cref="SiteMapping.SourceSiteIdentifier"/>); when the bundle carries
/// no explicit entry we auto-match by identity — an existing target site with
/// the same identifier resolves to <see cref="MappingAction.MapToExisting"/>,
/// otherwise <see cref="MappingAction.CreateNew"/>. <c>CreateNew</c> inserts a
/// site from the full <see cref="SiteDto"/> payload (display name, description,
/// and the verbatim Node A/B + gRPC Node A/B addresses — D3's "carry full
/// config" decision). <c>MapToExisting</c> honours the site's
/// <see cref="ImportResolution"/>: <see cref="ResolutionAction.Skip"/> leaves
/// the target untouched; <see cref="ResolutionAction.Overwrite"/> applies the
/// bundle's fields onto the existing row.
/// </para>
/// <para>
/// Sites that are <em>referenced</em> by an instance (or data connection) but
/// not carried in <see cref="BundleContentDto.Sites"/> must already exist in
/// the target — they auto-match to the existing target site. An unresolvable
/// reference is a hard error (caught at preview as a blocker; guarded
/// defensively here so a stale resolution payload can't write an orphan FK).
/// </para>
/// </summary>
private async Task<Dictionary<string, Site>> ApplySitesAsync(
BundleContentDto content,
BundleNameMap nameMap,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
ImportSummary summary,
CancellationToken ct)
{
var result = new Dictionary<string, Site>(StringComparer.Ordinal);
var siteMappingByIdentifier = nameMap.Sites
.GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
// Pass 1 — sites the bundle CARRIES (full SiteDto payload available).
foreach (var siteDto in content.Sites)
{
if (result.ContainsKey(siteDto.SiteIdentifier)) continue;
siteMappingByIdentifier.TryGetValue(siteDto.SiteIdentifier, out var mapping);
var existing = await _siteRepo
.GetSiteByIdentifierAsync(siteDto.SiteIdentifier, ct).ConfigureAwait(false);
// Auto-match when the operator didn't supply an explicit mapping:
// existing target → MapToExisting, otherwise CreateNew.
var action = mapping?.Action
?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
// An explicit MapToExisting can target a DIFFERENTLY-named site; honour
// the operator's TargetSiteIdentifier when supplied, else fall back to
// the same-identifier match.
Site? target = existing;
if (action == MappingAction.MapToExisting
&& mapping?.TargetSiteIdentifier is { Length: > 0 } targetId
&& !string.Equals(targetId, siteDto.SiteIdentifier, StringComparison.Ordinal))
{
target = await _siteRepo.GetSiteByIdentifierAsync(targetId, ct).ConfigureAwait(false);
}
if (action == MappingAction.CreateNew || target is null)
{
var created = BuildSite(siteDto);
await _siteRepo.AddSiteAsync(created, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Site", "0", created.SiteIdentifier,
new { created.SiteIdentifier, created.Name }, ct).ConfigureAwait(false);
summary.Added++;
result[siteDto.SiteIdentifier] = created;
continue;
}
// MapToExisting — honour the site's own conflict resolution.
var resolution = ResolveOrDefault(resolutionMap, "Site", siteDto.SiteIdentifier);
if (resolution.Action == ResolutionAction.Overwrite)
{
ApplySiteFields(target, siteDto);
await _siteRepo.UpdateSiteAsync(target, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "Site", target.Id.ToString(), target.SiteIdentifier,
new { target.SiteIdentifier, target.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
}
else
{
// Skip / Add(no-op for existing) — leave the target untouched.
summary.Skipped++;
}
result[siteDto.SiteIdentifier] = target;
}
// Pass 2 — sites REFERENCED but not carried (instances / connections).
// These must already exist in the target (no DTO to create from). Auto-
// match by identity; honour an explicit MapToExisting redirect.
foreach (var identifier in EnumerateReferencedSiteIdentifiers(content))
{
if (result.ContainsKey(identifier)) continue;
siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
var lookupId = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetSiteIdentifier is { Length: > 0 } t
? t
: identifier;
var target = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
if (target is null)
{
// Defensive guard — preview surfaces this as a blocker, but a stale
// resolution payload could still reach apply. Fail the whole import
// rather than write an instance/connection with an orphan SiteId.
throw new InvalidOperationException(
$"Site '{identifier}' is referenced by the bundle but not present in the target "
+ "and not carried in the bundle — cannot resolve a target site.");
}
result[identifier] = target;
}
return result;
}
/// <summary>
/// Distinct source-site identifiers referenced by instances and data
/// connections but not necessarily carried as a <see cref="SiteDto"/>.
/// </summary>
private static IEnumerable<string> EnumerateReferencedSiteIdentifiers(BundleContentDto content)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var i in content.Instances)
{
if (!string.IsNullOrEmpty(i.SiteIdentifier) && seen.Add(i.SiteIdentifier))
yield return i.SiteIdentifier;
}
foreach (var dc in content.DataConnections)
{
if (!string.IsNullOrEmpty(dc.SiteIdentifier) && seen.Add(dc.SiteIdentifier))
yield return dc.SiteIdentifier;
}
}
private static Site BuildSite(SiteDto dto) => new(dto.Name, dto.SiteIdentifier)
{
Description = dto.Description,
NodeAAddress = dto.NodeAAddress,
NodeBAddress = dto.NodeBAddress,
GrpcNodeAAddress = dto.GrpcNodeAAddress,
GrpcNodeBAddress = dto.GrpcNodeBAddress,
};
private static void ApplySiteFields(Site target, SiteDto dto)
{
target.Name = dto.Name;
target.Description = dto.Description;
target.NodeAAddress = dto.NodeAAddress;
target.NodeBAddress = dto.NodeBAddress;
target.GrpcNodeAAddress = dto.GrpcNodeAAddress;
target.GrpcNodeBAddress = dto.GrpcNodeBAddress;
}
/// <summary>
/// The resolved-connection maps the instance pass needs:
/// <see cref="IdBySourceRef"/> rewires connection-binding FKs
/// (<c>(sourceSite, sourceName) → target DataConnectionId</c>);
/// <see cref="TargetNameBySourceRef"/> rewrites native-alarm-source
/// <c>ConnectionNameOverride</c> values to the MAPPED target connection name
/// (<c>(sourceSite, sourceName) → target connection Name</c>) so a
/// differently-named MapToExisting redirect carries through.
/// </summary>
private readonly record struct ResolvedConnectionMaps(
Dictionary<(string Site, string Name), int> IdBySourceRef,
Dictionary<(string Site, string Name), string> TargetNameBySourceRef);
/// <summary>
/// Resolve-or-create every target <see cref="DataConnection"/> the bundle
/// references, returning the id + target-name maps (see
/// <see cref="ResolvedConnectionMaps"/>) the instance pass uses to rewire
/// connection-binding FKs and native-alarm-source <c>ConnectionNameOverride</c>
/// rewrites.
/// <para>
/// A connection's mapping is taken from <paramref name="nameMap"/> (matched by
/// <see cref="ConnectionMapping.SourceSiteIdentifier"/> +
/// <see cref="ConnectionMapping.SourceConnectionName"/>); with no explicit
/// entry we auto-match by name WITHIN the mapped target site. <c>CreateNew</c>
/// inserts a connection under the mapped target site, restoring
/// <c>PrimaryConfiguration</c> / <c>BackupConfiguration</c> from the DTO's
/// <see cref="SecretsBlock"/>. <c>MapToExisting</c> honours the connection's
/// <see cref="ImportResolution"/> (Overwrite applies the bundle fields; Skip
/// leaves the target row untouched).
/// </para>
/// </summary>
private async Task<ResolvedConnectionMaps> ApplyDataConnectionsAsync(
BundleContentDto content,
BundleNameMap nameMap,
Dictionary<string, Site> siteBySourceIdentifier,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
ImportSummary summary,
CancellationToken ct)
{
var result = new Dictionary<(string, string), int>();
var targetNameByRef = new Dictionary<(string Site, string Name), string>();
var connMappingByRef = nameMap.Connections
.GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
.ToDictionary(g => g.Key, g => g.First());
// Memoise the target site's existing connections (one query per site).
var targetConnsBySiteId = new Dictionary<int, IReadOnlyList<DataConnection>>();
async Task<IReadOnlyList<DataConnection>> TargetConnsAsync(int siteId)
{
if (targetConnsBySiteId.TryGetValue(siteId, out var cached)) return cached;
var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, ct).ConfigureAwait(false);
targetConnsBySiteId[siteId] = conns;
return conns;
}
foreach (var dcDto in content.DataConnections)
{
var key = (dcDto.SiteIdentifier, dcDto.Name);
if (result.ContainsKey(key)) continue;
if (!siteBySourceIdentifier.TryGetValue(dcDto.SiteIdentifier, out var targetSite))
{
// Should never happen: ApplySitesAsync resolved every referenced
// site (including those only referenced by a connection). Guard so a
// missing entry fails the import instead of writing an orphan FK.
throw new InvalidOperationException(
$"Data connection '{dcDto.SiteIdentifier}/{dcDto.Name}' references a site that "
+ "could not be resolved to a target.");
}
connMappingByRef.TryGetValue(key, out var mapping);
var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
// The connection name to match in the target — an explicit
// MapToExisting may redirect to a differently-named target connection.
var targetName = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetConnectionName is { Length: > 0 } tn
? tn
: dcDto.Name;
var existing = targetConns.FirstOrDefault(c =>
string.Equals(c.Name, targetName, StringComparison.Ordinal));
var action = mapping?.Action
?? (existing is not null ? MappingAction.MapToExisting : MappingAction.CreateNew);
if (action == MappingAction.CreateNew || existing is null)
{
var created = BuildDataConnection(dcDto, targetSite.Id);
await _siteRepo.AddDataConnectionAsync(created, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "DataConnection", "0",
$"{targetSite.SiteIdentifier}/{created.Name}",
new { created.Name, created.Protocol, SiteIdentifier = targetSite.SiteIdentifier }, ct)
.ConfigureAwait(false);
summary.Added++;
result[key] = created.Id;
targetNameByRef[key] = created.Name;
continue;
}
// MapToExisting — honour the connection's own conflict resolution.
var resolution = ResolveOrDefault(
resolutionMap, "DataConnection", dcDto.Name);
if (resolution.Action == ResolutionAction.Overwrite)
{
ApplyDataConnectionFields(existing, dcDto);
await _siteRepo.UpdateDataConnectionAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "DataConnection", existing.Id.ToString(),
$"{targetSite.SiteIdentifier}/{existing.Name}",
new { existing.Name, existing.Protocol }, ct).ConfigureAwait(false);
summary.Overwritten++;
}
else
{
summary.Skipped++;
}
result[key] = existing.Id;
// existing.Name is the TARGET connection name — for a same-name match
// it equals dcDto.Name, for a redirect it's the redirected target.
targetNameByRef[key] = existing.Name;
}
return new ResolvedConnectionMaps(result, targetNameByRef);
}
private static DataConnection BuildDataConnection(DataConnectionDto dto, int siteId) =>
new(dto.Name, dto.Protocol, siteId)
{
FailoverRetryCount = dto.FailoverRetryCount,
PrimaryConfiguration =
dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null,
BackupConfiguration =
dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null,
};
private static void ApplyDataConnectionFields(DataConnection target, DataConnectionDto dto)
{
target.Protocol = dto.Protocol;
target.FailoverRetryCount = dto.FailoverRetryCount;
target.PrimaryConfiguration =
dto.Secrets?.Values.TryGetValue("PrimaryConfiguration", out var pc) == true ? pc : null;
target.BackupConfiguration =
dto.Secrets?.Values.TryGetValue("BackupConfiguration", out var bc) == true ? bc : null;
}
/// <summary>
/// Upsert every <see cref="InstanceDto"/> in the bundle, rewiring its
/// cross-environment name references to the target's surrogate keys:
/// <list type="bullet">
/// <item><c>TemplateName</c> → target template id (just-imported set first,
/// then pre-existing target); an unresolved template was a preview blocker —
/// guarded here so a stale payload fails the import rather than writing an
/// orphan FK.</item>
/// <item><c>SiteIdentifier</c> → target site id (from the site map).</item>
/// <item><c>AreaName</c> → an area under the target site, created if missing.</item>
/// <item>each <c>ConnectionBinding.ConnectionName</c> → target
/// <c>DataConnectionId</c> (from the connection map).</item>
/// <item>each <c>NativeAlarmSourceOverride.ConnectionNameOverride</c> →
/// rewritten to the MAPPED target connection name.</item>
/// </list>
/// Imported instances are always written with <see cref="InstanceState.NotDeployed"/>
/// — an imported instance is design-time configuration, never carried as
/// live/deployed across environments. Identity is the <c>UniqueName</c>
/// (hydrated via <see cref="ITemplateEngineRepository.GetInstanceByUniqueNameAsync"/>,
/// which eager-loads all four child collections). The instance's
/// <see cref="ImportResolution"/> drives Add / Overwrite / Skip / Rename; on
/// Overwrite the existing child rows are deleted-then-readded so the bundle is
/// the source of truth (mirrors the template-overwrite child-sync pattern).
/// </summary>
private async Task ApplyInstancesAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
Dictionary<string, Site> siteBySourceIdentifier,
ResolvedConnectionMaps connectionMaps,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (content.Instances.Count == 0) return;
// Build a target-template name→id map once (just-imported templates were
// flushed before this pass; pre-existing target templates count too).
var templateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
.GroupBy(t => t.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
// Memoise area resolution per (siteId, areaName) so two instances under the
// same area don't each create a duplicate row.
var areaIdByKey = new Dictionary<(int SiteId, string Name), int>();
foreach (var dto in content.Instances)
{
var resolution = ResolveOrDefault(resolutionMap, "Instance", dto.UniqueName);
if (resolution.Action == ResolutionAction.Skip)
{
summary.Skipped++;
continue;
}
// Resolve the template id by name (post-rename: an imported template
// resolved as Rename was written under RenameTo, which is what the
// instance's TemplateName must already match in a self-consistent
// bundle; v1 does not rewrite instance TemplateName references).
if (!templateIdByName.TryGetValue(dto.TemplateName, out var templateId))
{
throw new InvalidOperationException(
$"Instance '{dto.UniqueName}' references template '{dto.TemplateName}' which is "
+ "present in neither the bundle nor the target.");
}
if (!siteBySourceIdentifier.TryGetValue(dto.SiteIdentifier, out var targetSite))
{
throw new InvalidOperationException(
$"Instance '{dto.UniqueName}' references site '{dto.SiteIdentifier}' which could "
+ "not be resolved to a target.");
}
int? areaId = await ResolveOrCreateAreaIdAsync(
dto.AreaName, targetSite.Id, areaIdByKey, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.UniqueName;
var inst = BuildInstance(
dto, name, templateId, targetSite, areaId, connectionMaps);
await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Instance", "0", name,
new { UniqueName = name, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier, RenamedFrom = dto.UniqueName },
ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite:
{
var existing = await _templateRepo
.GetInstanceByUniqueNameAsync(dto.UniqueName, ct).ConfigureAwait(false);
if (existing is null)
{
// Overwrite chosen but no target row — treat as Add (the
// preview's "existing" read may have raced a delete). Write
// a fresh instance under the bundle's UniqueName.
var added = BuildInstance(
dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
await _templateRepo.AddInstanceAsync(added, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Instance", "0", added.UniqueName,
new { added.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
ct).ConfigureAwait(false);
summary.Added++;
break;
}
// Rewire scalar FKs + state, then replace child rows.
existing.TemplateId = templateId;
existing.SiteId = targetSite.Id;
existing.AreaId = areaId;
existing.State = InstanceState.NotDeployed;
await ReplaceInstanceChildrenAsync(
existing, dto, dto.SiteIdentifier, connectionMaps, ct)
.ConfigureAwait(false);
await _templateRepo.UpdateInstanceAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "Instance", existing.Id.ToString(), existing.UniqueName,
new { existing.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
ct).ConfigureAwait(false);
summary.Overwritten++;
break;
}
case ResolutionAction.Add:
default:
{
var inst = BuildInstance(
dto, dto.UniqueName, templateId, targetSite, areaId, connectionMaps);
await _templateRepo.AddInstanceAsync(inst, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Instance", "0", inst.UniqueName,
new { inst.UniqueName, dto.TemplateName, SiteIdentifier = targetSite.SiteIdentifier },
ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
/// <summary>
/// Resolves an instance's area by name within the target site, creating the
/// area if it doesn't exist. Returns null when <paramref name="areaName"/> is
/// null/empty (the instance has no area). Memoised via
/// <paramref name="areaIdByKey"/> so repeated references resolve to one row.
/// </summary>
private async Task<int?> ResolveOrCreateAreaIdAsync(
string? areaName,
int siteId,
Dictionary<(int SiteId, string Name), int> areaIdByKey,
CancellationToken ct)
{
if (string.IsNullOrEmpty(areaName)) return null;
var key = (siteId, areaName);
if (areaIdByKey.TryGetValue(key, out var cached)) return cached;
var existingAreas = await _templateRepo.GetAreasBySiteIdAsync(siteId, ct).ConfigureAwait(false);
var match = existingAreas.FirstOrDefault(a => string.Equals(a.Name, areaName, StringComparison.Ordinal));
if (match is not null)
{
areaIdByKey[key] = match.Id;
return match.Id;
}
var area = new Area(areaName) { SiteId = siteId };
await _templateRepo.AddAreaAsync(area, ct).ConfigureAwait(false);
// Flush so the area's surrogate id materialises before it's used as an
// instance FK (relational provider). Rides the outer transaction.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
areaIdByKey[key] = area.Id;
return area.Id;
}
/// <summary>
/// Builds a new <see cref="Instance"/> from a DTO with all four child
/// collections populated and every cross-environment FK rewired to the
/// target. State is always <see cref="InstanceState.NotDeployed"/>.
/// </summary>
private static Instance BuildInstance(
InstanceDto dto,
string uniqueName,
int templateId,
Site targetSite,
int? areaId,
ResolvedConnectionMaps connectionMaps)
{
var inst = new Instance(uniqueName)
{
TemplateId = templateId,
SiteId = targetSite.Id,
AreaId = areaId,
State = InstanceState.NotDeployed,
};
// The connection maps are keyed by the SOURCE site identifier (dto.SiteIdentifier),
// NOT the target's — a cross-site MapToExisting redirect resolves the binding
// through the source key, then the map already points at the target's id/name.
PopulateInstanceChildren(inst, dto, dto.SiteIdentifier, connectionMaps);
return inst;
}
/// <summary>
/// Populates the four child collections on a tracked/new instance from the
/// DTO. Connection bindings rewire <c>ConnectionName</c> → the target
/// <c>DataConnectionId</c>; native-alarm-source overrides rewrite
/// <c>ConnectionNameOverride</c> to the MAPPED target connection name. The
/// connection map is keyed by (sourceSiteIdentifier, sourceConnectionName) —
/// the instance's own <c>SiteIdentifier</c> is the source-site key.
/// </summary>
private static void PopulateInstanceChildren(
Instance inst,
InstanceDto dto,
string sourceSiteIdentifier,
ResolvedConnectionMaps connectionMaps)
{
foreach (var o in dto.AttributeOverrides)
{
inst.AttributeOverrides.Add(new InstanceAttributeOverride(o.AttributeName)
{
OverrideValue = o.OverrideValue,
ElementDataType = o.ElementDataType,
});
}
foreach (var o in dto.AlarmOverrides)
{
inst.AlarmOverrides.Add(new InstanceAlarmOverride(o.AlarmCanonicalName)
{
TriggerConfigurationOverride = o.TriggerConfigurationOverride,
PriorityLevelOverride = o.PriorityLevelOverride,
});
}
foreach (var o in dto.NativeAlarmSourceOverrides)
{
// Rewrite the connection-name override to the MAPPED target connection
// name (the binding FK is an id, but this override stores a NAME). When
// the source name maps to a differently-named target connection we must
// carry the target name forward; otherwise keep the original (it already
// names a target connection, e.g. an unmapped pass-through).
inst.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride(o.SourceCanonicalName)
{
ConnectionNameOverride = RewriteConnectionName(
o.ConnectionNameOverride, sourceSiteIdentifier, connectionMaps),
SourceReferenceOverride = o.SourceReferenceOverride,
ConditionFilterOverride = o.ConditionFilterOverride,
});
}
foreach (var b in dto.ConnectionBindings)
{
// Resolve ConnectionName → target DataConnectionId. A binding whose
// connection didn't resolve was a preview blocker; default to 0 here
// (the FK constraint / a later deploy gate surfaces it) rather than
// throwing, since the binding's attribute may legitimately be unbound.
connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId);
inst.ConnectionBindings.Add(new InstanceConnectionBinding(b.AttributeName)
{
DataConnectionId = connId,
DataSourceReferenceOverride = b.DataSourceReferenceOverride,
});
}
}
/// <summary>
/// Replaces an existing instance's four child collections with the bundle's
/// (Overwrite semantics — the bundle is the source of truth). Deletes the
/// current rows via the repo, clears the tracked navigations, then re-adds
/// from the DTO with FKs rewired. Mirrors the template-overwrite child sync.
/// </summary>
private async Task ReplaceInstanceChildrenAsync(
Instance existing,
InstanceDto dto,
string sourceSiteIdentifier,
ResolvedConnectionMaps connectionMaps,
CancellationToken ct)
{
foreach (var o in existing.AttributeOverrides.ToList())
{
await _templateRepo.DeleteInstanceAttributeOverrideAsync(o.Id, ct).ConfigureAwait(false);
}
existing.AttributeOverrides.Clear();
foreach (var o in existing.AlarmOverrides.ToList())
{
await _templateRepo.DeleteInstanceAlarmOverrideAsync(o.Id, ct).ConfigureAwait(false);
}
existing.AlarmOverrides.Clear();
foreach (var o in existing.NativeAlarmSourceOverrides.ToList())
{
await _templateRepo.DeleteInstanceNativeAlarmSourceOverrideAsync(o.Id, ct).ConfigureAwait(false);
}
existing.NativeAlarmSourceOverrides.Clear();
foreach (var b in existing.ConnectionBindings.ToList())
{
await _templateRepo.DeleteInstanceConnectionBindingAsync(b.Id, ct).ConfigureAwait(false);
}
existing.ConnectionBindings.Clear();
PopulateInstanceChildren(existing, dto, sourceSiteIdentifier, connectionMaps);
}
/// <summary>
/// Rewrites a source connection-name reference (a native-alarm-source
/// <c>ConnectionNameOverride</c>) to the MAPPED target connection name. The
/// lookup is <c>(sourceSite, sourceName) → target Name</c> via the
/// target-name map <see cref="ApplyDataConnectionsAsync"/> built — so a
/// differently-named MapToExisting redirect (sourceConn "OpcA" mapped onto
/// target "OpcUaPrimary") carries through correctly. Returns the original
/// name unchanged when it doesn't resolve in the map (an unmapped
/// pass-through that already names a target connection — e.g. a connection
/// that auto-matched an existing target but wasn't carried in the bundle's
/// DataConnections, so it isn't in the map).
/// </summary>
private static string? RewriteConnectionName(
string? sourceName,
string sourceSiteIdentifier,
ResolvedConnectionMaps connectionMaps)
{
if (string.IsNullOrEmpty(sourceName)) return sourceName;
return connectionMaps.TargetNameBySourceRef.TryGetValue((sourceSiteIdentifier, sourceName), out var targetName)
? targetName
: sourceName;
}
/// <summary>
/// M8: validates every cross-environment reference the site/instance payload
/// depends on, BEFORE any row is staged — so a structurally-unresolvable
/// bundle fails with an empty change tracker (preserving the all-or-nothing
/// rollback contract on every EF provider, including the in-memory one whose
/// intermediate flush can't be undone by <c>ChangeTracker.Clear</c>).
/// <para>Checks, mirroring the resolve-or-create logic the apply passes use:</para>
/// <list type="bullet">
/// <item>every non-Skip instance's <c>TemplateName</c> resolves to an in-bundle
/// (non-Skip) template or a pre-existing target template;</item>
/// <item>every referenced site resolves — carried in the bundle (auto-creatable),
/// or mapped/auto-matched to an existing target site;</item>
/// <item>every referenced connection resolves — carried in the bundle
/// (auto-creatable under its mapped site), or mapped/auto-matched to an existing
/// connection in the mapped target site.</item>
/// </list>
/// </summary>
private async Task<IReadOnlyList<string>> ValidateSiteInstanceReferencesAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
BundleNameMap nameMap,
CancellationToken ct)
{
if (content.Instances.Count == 0 && content.DataConnections.Count == 0 && content.Sites.Count == 0)
{
return Array.Empty<string>();
}
var errors = new List<string>();
var siteMappingByIdentifier = nameMap.Sites
.GroupBy(m => m.SourceSiteIdentifier, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var connMappingByRef = nameMap.Connections
.GroupBy(m => (m.SourceSiteIdentifier, m.SourceConnectionName))
.ToDictionary(g => g.Key, g => g.First());
// Sites carried in the bundle are always resolvable (CreateNew or
// MapToExisting both yield a target).
var bundleSiteIdentifiers = new HashSet<string>(
content.Sites.Select(s => s.SiteIdentifier), StringComparer.Ordinal);
// Resolve a source site identifier → target Site (memoised). Null = the
// source site has no resolvable target AND isn't carried in the bundle.
var targetSiteCache = new Dictionary<string, Site?>(StringComparer.Ordinal);
async Task<Site?> ResolveTargetSiteAsync(string identifier)
{
if (targetSiteCache.TryGetValue(identifier, out var cached)) return cached;
siteMappingByIdentifier.TryGetValue(identifier, out var mapping);
var lookupId = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetSiteIdentifier is { Length: > 0 } t
? t
: identifier;
var site = await _siteRepo.GetSiteByIdentifierAsync(lookupId, ct).ConfigureAwait(false);
targetSiteCache[identifier] = site;
return site;
}
// ---- Site references (instances + connections + carried sites) ----
foreach (var identifier in EnumerateReferencedSiteIdentifiers(content)
.Concat(content.Sites.Select(s => s.SiteIdentifier))
.Distinct(StringComparer.Ordinal))
{
if (bundleSiteIdentifiers.Contains(identifier)) continue; // carried → creatable
var target = await ResolveTargetSiteAsync(identifier).ConfigureAwait(false);
if (target is null)
{
errors.Add(
$"Site '{identifier}' is referenced by the bundle but is neither carried in the "
+ "bundle nor resolvable to an existing target site.");
}
}
// ---- Connection references (bindings + native-alarm overrides + carried) ----
var bundleConnectionRefs = new HashSet<(string Site, string Name)>(
content.DataConnections.Select(dc => (dc.SiteIdentifier, dc.Name)));
var referencedConnections = new HashSet<(string Site, string Name)>(bundleConnectionRefs);
foreach (var inst in content.Instances)
{
var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
if (resolution.Action == ResolutionAction.Skip) continue;
foreach (var b in inst.ConnectionBindings)
{
if (!string.IsNullOrEmpty(b.ConnectionName))
referencedConnections.Add((inst.SiteIdentifier, b.ConnectionName));
}
foreach (var n in inst.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
referencedConnections.Add((inst.SiteIdentifier, n.ConnectionNameOverride));
}
}
foreach (var (site, name) in referencedConnections)
{
if (bundleConnectionRefs.Contains((site, name))) continue; // carried → creatable
var targetSite = await ResolveTargetSiteAsync(site).ConfigureAwait(false);
if (targetSite is null) continue; // already flagged above as a site error
connMappingByRef.TryGetValue((site, name), out var mapping);
var targetName = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetConnectionName is { Length: > 0 } tn
? tn
: name;
var conns = await _siteRepo.GetDataConnectionsBySiteIdAsync(targetSite.Id, ct).ConfigureAwait(false);
if (!conns.Any(c => string.Equals(c.Name, targetName, StringComparison.Ordinal)))
{
errors.Add(
$"Connection '{site}/{name}' is referenced by the bundle but is neither carried in "
+ "the bundle nor resolvable to an existing connection in the mapped target site.");
}
}
// ---- Instance template references ----
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
bundleTemplateNames.Add(t.Name);
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
bundleTemplateNames.Add(resolution.RenameTo);
}
var targetTemplateNames = new HashSet<string>(
(await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false)).Select(t => t.Name),
StringComparer.Ordinal);
foreach (var inst in content.Instances)
{
var resolution = ResolveOrDefault(resolutionMap, "Instance", inst.UniqueName);
if (resolution.Action == ResolutionAction.Skip) continue;
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
if (targetTemplateNames.Contains(inst.TemplateName)) continue;
errors.Add(
$"Instance '{inst.UniqueName}' references template '{inst.TemplateName}' which is "
+ "present in neither the bundle nor the target.");
}
return errors;
}
/// <summary>
/// Two-tier semantic validation run before any rows are flushed:
/// <list type="number">
@@ -0,0 +1,669 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
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.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.Import;
/// <summary>
/// Integration tests for the M8 D1 site/instance-scoped apply path of
/// <see cref="ZB.MOM.WW.ScadaBridge.Transport.Import.BundleImporter.ApplyAsync"/>:
/// resolve-or-create target sites + data connections from a <see cref="BundleNameMap"/>,
/// upsert instances, and rewire every cross-environment FK (connection-binding
/// <c>DataConnectionId</c>, native-alarm-source <c>ConnectionNameOverride</c>) onto
/// the target's surrogate keys.
/// <para>
/// Reuses the in-memory host pattern from <c>BundleImporterApplyTests</c> /
/// <c>BundleImporterPreviewTests</c>: real repositories, real EF in-memory provider,
/// real Transport pipeline. Bundles are produced by the real exporter (site closure)
/// or hand-packed via <see cref="BundleSerializer"/> for negative cases.
/// </para>
/// </summary>
public sealed class SiteInstanceImportTests : IDisposable
{
private readonly ServiceProvider _provider;
public SiteInstanceImportTests()
{
var services = new ServiceCollection();
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().AddInMemoryCollection().Build());
var dbName = $"SiteInstanceImportTests_{Guid.NewGuid()}";
// Same in-memory caveat as BundleImporterApplyTests: ApplyAsync opens a
// transaction (no-op on in-memory) and defers the single real
// SaveChangesAsync to just before CommitAsync; intermediate flushes are
// undone on the catch path via ChangeTracker.Clear(). Downgrade the
// transaction-ignored warning so the in-memory run proceeds.
services.AddDbContext<ScadaBridgeDbContext>(opts => opts
.UseInMemoryDatabase(dbName)
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddTransport();
_provider = services.BuildServiceProvider();
}
public void Dispose() => _provider.Dispose();
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Seeds a full site closure (template + site + site-scoped data connection +
/// instance bound to that connection, with an attribute override, an alarm
/// override and a native-alarm-source override whose ConnectionNameOverride
/// names the seeded connection) so the export carries every FK shape D1 must
/// rewire. The instance is seeded <see cref="InstanceState.Enabled"/> so the
/// import's NotDeployed reset is observable.
/// </summary>
private async Task SeedSiteClosureAsync(
string siteIdentifier = "plant-1",
string siteName = "Plant 1",
string connectionName = "OpcUaPrimary",
string templateName = "Pump",
string instanceName = "Pump-01")
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var template = new Template(templateName) { Description = "pump tpl" };
template.Attributes.Add(new TemplateAttribute("Flow") { Value = "0" });
ctx.Templates.Add(template);
var site = new Site(siteName, siteIdentifier)
{
Description = "primary plant",
NodeAAddress = "akka://site@10.0.0.1:2552",
NodeBAddress = "akka://site@10.0.0.2:2552",
GrpcNodeAAddress = "10.0.0.1:8083",
GrpcNodeBAddress = "10.0.0.2:8083",
};
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection(connectionName, "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
BackupConfiguration = "{\"endpoint\":\"opc.tcp://backup\"}",
FailoverRetryCount = 5,
};
ctx.DataConnections.Add(conn);
var instance = new Instance(instanceName)
{
TemplateId = template.Id,
SiteId = site.Id,
State = InstanceState.Enabled,
};
instance.AttributeOverrides.Add(new InstanceAttributeOverride("Flow") { OverrideValue = "42" });
instance.AlarmOverrides.Add(new InstanceAlarmOverride("HiAlarm") { PriorityLevelOverride = 7 });
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
{
ConnectionNameOverride = connectionName,
SourceReferenceOverride = "ns=3;s=Pump.Alarm",
});
ctx.Instances.Add(instance);
await ctx.SaveChangesAsync();
instance.ConnectionBindings.Add(new InstanceConnectionBinding("Flow")
{
DataConnectionId = conn.Id,
DataSourceReferenceOverride = "ns=3;s=Pump.Flow",
});
await ctx.SaveChangesAsync();
}
/// <summary>Exports every seeded site (and its instance/connection closure) into a bundle, then loads it.</summary>
private async Task<Guid> ExportAllSitesAndLoadAsync()
{
Stream bundleStream;
await using (var scope = _provider.CreateAsyncScope())
{
var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var siteIds = await ctx.Sites.Select(s => s.Id).ToListAsync();
var selection = new ExportSelection(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true,
SiteIds: siteIds);
bundleStream = await exporter.ExportAsync(selection, user: "alice", sourceEnvironment: "dev",
passphrase: null, cancellationToken: CancellationToken.None);
}
using var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms);
ms.Position = 0;
await using var loadScope = _provider.CreateAsyncScope();
var importer = loadScope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
/// <summary>Removes all site/instance-scoped rows so the import exercises the CreateNew path against a fresh target.</summary>
private async Task WipeSiteClosureAsync()
{
await using var scope = _provider.CreateAsyncScope();
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.InstanceConnectionBindings.RemoveRange(ctx.InstanceConnectionBindings);
ctx.InstanceAttributeOverrides.RemoveRange(ctx.InstanceAttributeOverrides);
ctx.InstanceAlarmOverrides.RemoveRange(ctx.InstanceAlarmOverrides);
ctx.InstanceNativeAlarmSourceOverrides.RemoveRange(ctx.InstanceNativeAlarmSourceOverrides);
ctx.Instances.RemoveRange(ctx.Instances);
ctx.DataConnections.RemoveRange(ctx.DataConnections);
ctx.Areas.RemoveRange(ctx.Areas);
ctx.Sites.RemoveRange(ctx.Sites);
ctx.Templates.RemoveRange(ctx.Templates);
await ctx.SaveChangesAsync();
}
private async Task<ImportResult> ApplyAsync(
Guid sessionId, IReadOnlyList<ImportResolution> resolutions, BundleNameMap nameMap)
{
await using var scope = _provider.CreateAsyncScope();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
return await importer.ApplyAsync(sessionId, resolutions, user: "bob", ct: CancellationToken.None, nameMap: nameMap);
}
/// <summary>
/// Hand-packs an arbitrary <see cref="BundleContentDto"/> into a real, loadable
/// plaintext bundle and opens a session. Lets negative-path tests carry an
/// instance whose TemplateName the export resolver would never emit (e.g. a
/// template absent from both bundle and target), so the instance pass' guard
/// can be exercised mid-transaction. Reuses the production manifest builder +
/// serializer for hash fidelity.
/// </summary>
private async Task<Guid> PackAndLoadAsync(BundleContentDto content)
{
await using var scope = _provider.CreateAsyncScope();
var manifestBuilder = scope.ServiceProvider.GetRequiredService<ManifestBuilder>();
var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var summary = new BundleSummary(
Templates: content.Templates.Count,
TemplateFolders: content.TemplateFolders.Count,
SharedScripts: content.SharedScripts.Count,
ExternalSystems: content.ExternalSystems.Count,
DbConnections: content.DatabaseConnections.Count,
NotificationLists: content.NotificationLists.Count,
SmtpConfigs: content.SmtpConfigs.Count,
ApiMethods: content.ApiMethods.Count,
Sites: content.Sites.Count,
DataConnections: content.DataConnections.Count,
Instances: content.Instances.Count);
var manifest = manifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: summary,
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: serializer.SerializeContentBytes(content));
await using var packed = serializer.Pack(content, manifest, passphrase: null, encryptor: null);
using var ms = new MemoryStream();
await packed.CopyToAsync(ms);
ms.Position = 0;
var session = await importer.LoadAsync(ms, passphrase: null);
return session.SessionId;
}
// ──────────────────────────────────────────────────────────────────────
// CreateNew into a fresh target
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_CreateNew_into_fresh_target_creates_site_connection_instance_with_rewired_FKs()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// The template must exist for the instance to resolve its TemplateId.
// The wipe removed it, so re-create it (a real import that carries the
// template would Add it; here we isolate the site/instance path).
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null), // already present
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var site = await ctx.Sites.SingleAsync(s => s.SiteIdentifier == "plant-1");
Assert.Equal("Plant 1", site.Name);
// Full config carried (D3 "carry full config").
Assert.Equal("10.0.0.1:8083", site.GrpcNodeAAddress);
Assert.Equal("akka://site@10.0.0.2:2552", site.NodeBAddress);
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
Assert.Equal(site.Id, conn.SiteId);
// Primary/Backup config restored from the bundle's secrets block.
Assert.Contains("opc.tcp://primary", conn.PrimaryConfiguration!);
Assert.Contains("opc.tcp://backup", conn.BackupConfiguration!);
Assert.Equal(5, conn.FailoverRetryCount);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.AttributeOverrides)
.Include(i => i.AlarmOverrides)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Imported instances are design-time config — never carried as deployed.
Assert.Equal(InstanceState.NotDeployed, inst.State);
Assert.Equal(site.Id, inst.SiteId);
// FK rewire — binding points at the CREATED connection's surrogate id.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(conn.Id, binding.DataConnectionId);
Assert.Equal("ns=3;s=Pump.Flow", binding.DataSourceReferenceOverride);
// Native-alarm override connection-name rewritten to the target name.
var native = Assert.Single(inst.NativeAlarmSourceOverrides);
Assert.Equal("OpcUaPrimary", native.ConnectionNameOverride);
// Override rows present.
Assert.Single(inst.AttributeOverrides);
Assert.Equal("42", inst.AttributeOverrides.Single().OverrideValue);
Assert.Single(inst.AlarmOverrides);
Assert.Equal(7, inst.AlarmOverrides.Single().PriorityLevelOverride);
}
// 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.
Assert.Empty(result.StaleInstanceIds);
}
// ──────────────────────────────────────────────────────────────────────
// MapToExisting into a populated target (FK remap to existing ids)
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_MapToExisting_remaps_binding_to_existing_target_connection_id()
{
// Source env: plant-1 / OpcUaPrimary. Build a bundle from it, then set up
// a DISTINCT target env that already has plant-1 + OpcUaPrimary with their
// OWN surrogate ids (achieved by wiping + reseeding so ids differ from the
// source's). The import must MapToExisting and rebind to the TARGET ids.
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// Re-seed the target with the SAME identifiers but fresh rows (no instance
// — the import brings it). Pad with throwaway rows first so the target's
// surrogate ids do not coincide with the source's by accident.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Throwaway site/connection to advance the id counters.
var pad = new Site("Pad", "pad-site");
ctx.Sites.Add(pad);
await ctx.SaveChangesAsync();
ctx.DataConnections.Add(new DataConnection("PadConn", "OpcUa", pad.Id));
await ctx.SaveChangesAsync();
ctx.Templates.Add(new Template("Pump") { Description = "target pump" });
var site = new Site("Plant 1 (target)", "plant-1");
ctx.Sites.Add(site);
await ctx.SaveChangesAsync();
var conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://target-existing\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
targetConnId = conn.Id;
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null), // leave target site untouched
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null), // leave target conn untouched
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// Skip left the existing target connection's config untouched — NOT a
// new connection created from the bundle.
var conns = await ctx.DataConnections.Where(c => c.Name == "OpcUaPrimary").ToListAsync();
var conn = Assert.Single(conns);
Assert.Equal(targetConnId, conn.Id);
Assert.Contains("target-existing", conn.PrimaryConfiguration!);
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// FK remapped explicitly to the EXISTING target connection id.
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(targetConnId, binding.DataConnectionId);
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
}
// Site + connection Skipped, instance Added.
Assert.Equal(1, result.Added);
Assert.Equal(3, result.Skipped); // template + site + connection
}
// ──────────────────────────────────────────────────────────────────────
// Rename an instance on import
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_renames_instance_unique_name_on_import()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Rename, "Pump-01-imported"),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(0, await ctx.Instances.CountAsync(i => i.UniqueName == "Pump-01"));
var renamed = await ctx.Instances
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01-imported");
// Renamed instance still gets its FKs rewired + state reset.
Assert.Equal(InstanceState.NotDeployed, renamed.State);
var conn = await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary");
Assert.Equal(conn.Id, renamed.ConnectionBindings.Single().DataConnectionId);
}
Assert.Equal(1, result.Renamed);
}
// ──────────────────────────────────────────────────────────────────────
// Cross-site rebind: source site maps to a DIFFERENTLY-named target site
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_maps_source_site_to_differently_named_target_site_and_rebinds_connections()
{
await SeedSiteClosureAsync(siteIdentifier: "plant-1", connectionName: "OpcUaPrimary");
var sessionId = await ExportAllSitesAndLoadAsync();
await WipeSiteClosureAsync();
// Target env has a DIFFERENTLY-identified site (plant-west) carrying a
// same-named connection. The operator maps plant-1 → plant-west.
int westSiteId;
int westConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
var west = new Site("Plant West", "plant-west");
ctx.Sites.Add(west);
await ctx.SaveChangesAsync();
westSiteId = west.Id;
var conn = new DataConnection("OpcUaPrimary", "OpcUa", west.Id)
{
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://west\"}",
};
ctx.DataConnections.Add(conn);
await ctx.SaveChangesAsync();
westConnId = conn.Id;
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-west") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
// No plant-1 site was created — the bundle's site mapped onto plant-west.
Assert.Equal(0, await ctx.Sites.CountAsync(s => s.SiteIdentifier == "plant-1"));
var inst = await ctx.Instances
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Instance rebound to the TARGET site + its connection id.
Assert.Equal(westSiteId, inst.SiteId);
Assert.Equal(westConnId, inst.ConnectionBindings.Single().DataConnectionId);
}
Assert.Equal(1, result.Added);
}
// ──────────────────────────────────────────────────────────────────────
// Rollback: a mid-apply failure persists NOTHING
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_rolls_back_site_and_connection_when_instance_pass_throws()
{
// Hand-pack a bundle whose instance references template "GhostTemplate"
// that exists in NEITHER the bundle NOR the (empty) target. The reference
// is rejected in the pre-write validation phase (so the change tracker is
// still empty), the import aborts, and the transaction rolls back — no
// site, connection, or instance row may survive, and a BundleImportFailed
// audit row records the abort. The validation-phase rejection surfaces as
// a SemanticValidationException, the same all-or-nothing failure contract
// the script-reference and template-validation checks already use.
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 = new[]
{
new DataConnectionDto("plant-1", "OpcUaPrimary", "OpcUa", 3, null),
},
Instances = new[]
{
new InstanceDto(
UniqueName: "Pump-01",
TemplateName: "GhostTemplate",
SiteIdentifier: "plant-1",
AreaName: null,
State: InstanceState.Enabled,
AttributeOverrides: Array.Empty<InstanceAttributeOverrideDto>(),
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>()),
},
};
var sessionId = await PackAndLoadAsync(content);
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.CreateNew, null) });
await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Site", "plant-1", ResolutionAction.Add, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Add, null),
new("Instance", "Pump-01", ResolutionAction.Add, null),
},
user: "bob",
ct: CancellationToken.None,
nameMap: nameMap));
}
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.Equal(0, await ctx.Sites.CountAsync());
Assert.Equal(0, await ctx.DataConnections.CountAsync());
Assert.Equal(0, await ctx.Instances.CountAsync());
// A BundleImportFailed audit row records the aborted import.
Assert.True(await ctx.AuditLogEntries.AnyAsync(a => a.Action == "BundleImportFailed"));
}
}
// ──────────────────────────────────────────────────────────────────────
// Overwrite an existing instance: child rows replaced from the bundle
// ──────────────────────────────────────────────────────────────────────
[Fact]
public async Task ApplyAsync_Overwrite_existing_instance_replaces_child_rows_and_remaps_binding()
{
await SeedSiteClosureAsync();
var sessionId = await ExportAllSitesAndLoadAsync();
// Mutate the target instance so its children diverge from the bundle:
// drop the override + binding, add a junk override. Overwrite must restore
// the bundle's shape and rebind to the (still-present) target connection.
int targetConnId;
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
targetConnId = (await ctx.DataConnections.SingleAsync(c => c.Name == "OpcUaPrimary")).Id;
var inst = await ctx.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.ConnectionBindings)
.SingleAsync(i => i.UniqueName == "Pump-01");
ctx.InstanceAttributeOverrides.RemoveRange(inst.AttributeOverrides);
ctx.InstanceConnectionBindings.RemoveRange(inst.ConnectionBindings);
inst.AttributeOverrides.Clear();
inst.ConnectionBindings.Clear();
inst.AttributeOverrides.Add(new InstanceAttributeOverride("Junk") { OverrideValue = "stale" });
await ctx.SaveChangesAsync();
}
var nameMap = new BundleNameMap(
Sites: new[] { new SiteMapping("plant-1", MappingAction.MapToExisting, "plant-1") },
Connections: new[] { new ConnectionMapping("plant-1", "OpcUaPrimary", MappingAction.MapToExisting, "OpcUaPrimary") });
var result = await ApplyAsync(
sessionId,
new List<ImportResolution>
{
new("Template", "Pump", ResolutionAction.Skip, null),
new("Site", "plant-1", ResolutionAction.Skip, null),
new("DataConnection", "OpcUaPrimary", ResolutionAction.Skip, null),
new("Instance", "Pump-01", ResolutionAction.Overwrite, null),
},
nameMap);
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var inst = await ctx.Instances
.Include(i => i.AttributeOverrides)
.Include(i => i.ConnectionBindings)
.Include(i => i.NativeAlarmSourceOverrides)
.SingleAsync(i => i.UniqueName == "Pump-01");
// Bundle's child shape restored — junk override gone, Flow override + binding back.
Assert.DoesNotContain(inst.AttributeOverrides, o => o.AttributeName == "Junk");
Assert.Contains(inst.AttributeOverrides, o => o.AttributeName == "Flow" && o.OverrideValue == "42");
var binding = Assert.Single(inst.ConnectionBindings);
Assert.Equal(targetConnId, binding.DataConnectionId);
Assert.Equal("OpcUaPrimary", inst.NativeAlarmSourceOverrides.Single().ConnectionNameOverride);
Assert.Equal(InstanceState.NotDeployed, inst.State);
}
Assert.Equal(1, result.Overwritten);
}
}