feat(transport): apply site/instance import with name-map + FK rewire (M8 D1, T18)
This commit is contained in:
@@ -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">
|
||||
|
||||
+669
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user