Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Transport/Import/BundleImporter.cs
T

3609 lines
178 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
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;
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.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
/// <summary>
/// Three-phase bundle importer: <see cref="LoadAsync"/> validates the
/// bundle envelope (manifest + content hash + decryption) and opens a
/// session; <see cref="PreviewAsync"/> diffs the bundle's DTOs against the
/// current target database; <see cref="ApplyAsync"/> writes the chosen
/// resolutions through the audited repositories. All three phases are
/// fully implemented.
/// <para>
/// Audit-row responsibility: repository mutation methods in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories</c> are thin EF wrappers
/// and do NOT emit audit rows. <see cref="ApplyAsync"/> therefore writes
/// each per-entity audit row explicitly via <see cref="IAuditService.LogAsync"/>;
/// the scoped <see cref="IAuditCorrelationContext.BundleImportId"/> is
/// automatically stamped on each row by the audit service.
/// </para>
/// <para>
/// If repository methods are ever changed to emit audit rows themselves,
/// the explicit <c>LogAsync</c> calls in this class must be removed to
/// avoid double-logging.
/// </para>
/// </summary>
public sealed class BundleImporter : IBundleImporter
{
// All bundle content deserialization goes through BundleJsonOptions.Default.
// IMPORTANT — unknown JSON properties must remain ALLOWED (the default
// JsonUnmappedMemberHandling.Skip). Pre-C4 bundles may carry a top-level
// "apiKeys" array and/or "ApprovedApiKeyIds" inside "apiMethods[]" entries.
// Setting JsonUnmappedMemberHandling.Disallow here would cause those bundles
// to fail deserialization, breaking backward-compat. This tolerance is
// load-bearing and must not be changed. (Fix I-2)
private static readonly JsonSerializerOptions ContentJsonOptions = BundleJsonOptions.Default;
private readonly BundleSerializer _bundleSerializer;
private readonly ManifestValidator _manifestValidator;
private readonly BundleSecretEncryptor _encryptor;
private readonly ArtifactDiff _diff = new();
private readonly EntitySerializer _entitySerializer;
private readonly IAuditService _auditService;
private readonly IAuditCorrelationContext _correlationContext;
private readonly ScadaBridgeDbContext _dbContext;
private readonly ITemplateEngineRepository _templateRepo;
private readonly IExternalSystemRepository _externalRepo;
private readonly INotificationRepository _notificationRepo;
private readonly IInboundApiRepository _inboundApiRepo;
private readonly ISiteRepository _siteRepo;
private readonly IBundleSessionStore _sessionStore;
private readonly BundleUnlockRateLimiter _unlockRateLimiter;
private readonly IOptions<TransportOptions> _options;
private readonly TimeProvider _timeProvider;
private readonly SemanticValidator _semanticValidator;
private readonly ILogger<BundleImporter>? _logger;
/// <summary>
/// Initializes a new <see cref="BundleImporter"/> with all required dependencies.
/// </summary>
/// <param name="bundleSerializer">Serializer for reading bundle zip archives.</param>
/// <param name="manifestValidator">Validates the bundle manifest on load.</param>
/// <param name="encryptor">Handles AES-256-GCM decryption of encrypted bundles.</param>
/// <param name="entitySerializer">Deserializes entity DTOs from bundle content.</param>
/// <param name="sessionStore">In-memory session store for loaded bundle state.</param>
/// <param name="unlockRateLimiter">Rate limiter for passphrase unlock attempts per client IP.</param>
/// <param name="options">Transport configuration options.</param>
/// <param name="timeProvider">Abstracted time provider for testability.</param>
/// <param name="templateRepo">Template engine repository for diff and apply.</param>
/// <param name="externalRepo">External system repository for diff and apply.</param>
/// <param name="notificationRepo">Notification repository for diff and apply.</param>
/// <param name="inboundApiRepo">Inbound API repository for diff and apply.</param>
/// <param name="siteRepo">Site repository — supplies the target sites and site-scoped data connections that the preview's site/connection auto-match resolves against (M8 C2).</param>
/// <param name="auditService">Audit service for writing per-entity import audit rows.</param>
/// <param name="correlationContext">Correlation context that carries the active BundleImportId.</param>
/// <param name="dbContext">EF Core context used to commit the import transaction.</param>
/// <param name="semanticValidator">Validates template references before applying.</param>
public BundleImporter(
BundleSerializer bundleSerializer,
ManifestValidator manifestValidator,
BundleSecretEncryptor encryptor,
EntitySerializer entitySerializer,
IBundleSessionStore sessionStore,
BundleUnlockRateLimiter unlockRateLimiter,
IOptions<TransportOptions> options,
TimeProvider timeProvider,
ITemplateEngineRepository templateRepo,
IExternalSystemRepository externalRepo,
INotificationRepository notificationRepo,
IInboundApiRepository inboundApiRepo,
ISiteRepository siteRepo,
IAuditService auditService,
IAuditCorrelationContext correlationContext,
ScadaBridgeDbContext dbContext,
SemanticValidator semanticValidator,
ILogger<BundleImporter>? logger = null)
{
_bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer));
_manifestValidator = manifestValidator ?? throw new ArgumentNullException(nameof(manifestValidator));
_encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor));
_entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer));
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
_unlockRateLimiter = unlockRateLimiter ?? throw new ArgumentNullException(nameof(unlockRateLimiter));
_options = options ?? throw new ArgumentNullException(nameof(options));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_templateRepo = templateRepo ?? throw new ArgumentNullException(nameof(templateRepo));
_externalRepo = externalRepo ?? throw new ArgumentNullException(nameof(externalRepo));
_notificationRepo = notificationRepo ?? throw new ArgumentNullException(nameof(notificationRepo));
_inboundApiRepo = inboundApiRepo ?? throw new ArgumentNullException(nameof(inboundApiRepo));
_siteRepo = siteRepo ?? throw new ArgumentNullException(nameof(siteRepo));
_auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
_correlationContext = correlationContext ?? throw new ArgumentNullException(nameof(correlationContext));
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_semanticValidator = semanticValidator ?? throw new ArgumentNullException(nameof(semanticValidator));
_logger = logger;
}
/// <inheritdoc />
public async Task<BundleSession> LoadAsync(Stream bundleStream, string? passphrase, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(bundleStream);
// Copy to a seekable buffer — manifest + content readers each open a
// fresh ZipArchive over the same bytes, so the upstream stream needs to
// be seekable. A caller-supplied FileStream is seekable but a Kestrel
// request stream is not, so we always normalise to MemoryStream.
var ms = new MemoryStream();
await bundleStream.CopyToAsync(ms, ct).ConfigureAwait(false);
ms.Position = 0;
// Size cap is in MB; multiply in long arithmetic so the comparison
// doesn't overflow at the int boundary for large MaxBundleSizeMb.
var maxBytes = _options.Value.MaxBundleSizeMb * 1024L * 1024L;
if (ms.Length > maxBytes)
{
throw new InvalidOperationException(
$"Bundle exceeds maximum allowed size of {_options.Value.MaxBundleSizeMb} MB.");
}
// T-006: zip-bomb / decompression-bomb defences. We enforce three caps
// BEFORE any entry is decompressed by ReadManifest / ReadContentBytes:
// 1) total entry count
// 2) per-entry decompressed length
// 3) per-entry compression ratio (Length / CompressedLength)
// Each cap is configurable via TransportOptions so an operator can tune
// for an environment with legitimately large or unusually compressible
// bundles. Using ZipArchiveEntry.Length / .CompressedLength avoids
// reading the entry payload at all.
ms.Position = 0;
ValidateArchiveEnvelope(ms);
BundleManifest manifest;
try
{
ms.Position = 0;
manifest = _bundleSerializer.ReadManifest(ms);
}
catch (InvalidDataException)
{
// Preserve the serializer's specific "manifest missing/null" message
// — the caller wants to surface a precise diagnostic to the operator.
throw;
}
catch (Exception ex)
{
throw new InvalidDataException("Bundle is missing or has a malformed manifest.json.", ex);
}
ms.Position = 0;
var contentBytes = _bundleSerializer.ReadContentBytes(ms, manifest);
// Validate format version + content-hash + manifest shape. Reject paths
// surface as distinct exceptions so the UI can disambiguate the cause.
var validation = _manifestValidator.Validate(manifest, contentBytes);
switch (validation)
{
case ManifestValidationResult.UnsupportedFormatVersion:
throw new NotSupportedException(
$"Bundle format version {manifest.BundleFormatVersion} is not supported by this cluster.");
case ManifestValidationResult.ContentHashMismatch:
throw new InvalidDataException(
"Bundle content hash does not match manifest — file may be corrupt.");
case ManifestValidationResult.MalformedManifest:
throw new InvalidDataException("Bundle manifest is malformed.");
case ManifestValidationResult.Ok:
break;
default:
throw new InvalidDataException($"Unrecognised manifest validation result: {validation}.");
}
// Decrypt when the manifest carries EncryptionMetadata.
//
// T-003: lockout enforcement is server-side and keyed by ContentHash so a
// second tab / CLI caller re-uploading the same bundle bytes cannot
// side-step the limit by skipping the Razor page. The counter is
// consulted BEFORE attempting decrypt (rejects further attempts on an
// already-locked bundle) and incremented on a CryptographicException
// from _encryptor.Decrypt; a successful decrypt clears the counter so a
// legitimate operator who eventually types the right passphrase is not
// penalised for earlier typos.
byte[] decryptedContent;
if (manifest.Encryption is not null)
{
if (string.IsNullOrEmpty(passphrase))
{
throw new ArgumentException(
"Passphrase required for encrypted bundle.", nameof(passphrase));
}
var maxAttempts = _options.Value.MaxUnlockAttemptsPerSession;
var priorFailures = _sessionStore.GetUnlockFailureCount(manifest.ContentHash);
if (priorFailures >= maxAttempts)
{
throw new BundleLockedException(manifest.ContentHash, priorFailures);
}
// T-005: bind the manifest's non-derivative fields into AES-GCM AAD so
// a tampered SourceEnvironment / ExportedBy / etc. yields an
// authentication-tag mismatch (surfaced as CryptographicException) on
// decrypt — preventing a forged origin label from slipping past the
// Step-4 typo-resistant confirmation gate.
var aad = Encryption.BundleManifestAad.Compute(manifest);
try
{
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase, aad);
}
catch (CryptographicException)
{
var newCount = _sessionStore.IncrementUnlockFailureCount(manifest.ContentHash);
if (newCount >= maxAttempts)
{
// Surface the lockout as the typed exception so the caller can
// distinguish "wrong passphrase, try again" from "no more attempts".
throw new BundleLockedException(manifest.ContentHash, newCount);
}
// Otherwise rebubble the CryptographicException so the UI's
// wrong-passphrase audit + retry path continues to work unchanged.
throw;
}
_sessionStore.ClearUnlockFailures(manifest.ContentHash);
}
else
{
decryptedContent = contentBytes;
}
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
var session = new BundleSession
{
SessionId = Guid.NewGuid(),
Manifest = manifest,
DecryptedContent = decryptedContent,
ExpiresAt = _timeProvider.GetUtcNow() + ttl,
};
return _sessionStore.Open(session);
}
/// <summary>
/// T-006: validates the zip envelope against the configured caps BEFORE any
/// entry payload is decompressed. Reads only the central-directory headers
/// (<see cref="ZipArchiveEntry.Length"/> / <see cref="ZipArchiveEntry.CompressedLength"/>)
/// so a hostile bundle can't OOM the central node through this method itself.
/// </summary>
/// <param name="bundleBytes">Buffered bundle bytes. Position is preserved.</param>
private void ValidateArchiveEnvelope(MemoryStream bundleBytes)
{
var opts = _options.Value;
var maxEntries = opts.MaxBundleEntryCount;
var maxEntryDecompressed = opts.MaxBundleEntryDecompressedMb * 1024L * 1024L;
var maxRatio = opts.MaxBundleEntryCompressionRatio;
var savedPosition = bundleBytes.Position;
try
{
bundleBytes.Position = 0;
using var archive = new ZipArchive(bundleBytes, ZipArchiveMode.Read, leaveOpen: true);
if (archive.Entries.Count > maxEntries)
{
throw new InvalidDataException(
$"Bundle contains {archive.Entries.Count} zip entries; the configured maximum is {maxEntries}.");
}
foreach (var entry in archive.Entries)
{
if (entry.Length > maxEntryDecompressed)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' declares a decompressed size of {entry.Length} bytes; "
+ $"the configured maximum is {maxEntryDecompressed} bytes "
+ $"({opts.MaxBundleEntryDecompressedMb} MB).");
}
// CompressedLength of 0 means store-only or empty — skip ratio check.
if (entry.CompressedLength > 0 && entry.Length / entry.CompressedLength > maxRatio)
{
throw new InvalidDataException(
$"Bundle entry '{entry.FullName}' has compression ratio "
+ $"{entry.Length / entry.CompressedLength}x; the configured maximum is {maxRatio}x.");
}
}
}
finally
{
bundleBytes.Position = savedPosition;
}
}
/// <inheritdoc />
public async Task<ImportPreview> PreviewAsync(Guid sessionId, CancellationToken ct = default)
{
var session = _sessionStore.Get(sessionId)
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
if (session.Locked)
{
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
}
BundleContentDto content;
try
{
content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
?? throw new InvalidDataException("Session content deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
}
var items = new List<ImportPreviewItem>();
// ---- TemplateFolders ----
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var folderByName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
var folderNameById = allFolders.ToDictionary(f => f.Id, f => f.Name);
foreach (var fDto in content.TemplateFolders)
{
folderByName.TryGetValue(fDto.Name, out var existing);
items.Add(_diff.CompareTemplateFolder(fDto, existing, folderNameById));
}
// ---- Templates ----
// Transport-008: previously this loop iterated GetAllTemplatesAsync()
// and called GetTemplateWithChildrenAsync(stub.Id) once per matching
// name (classic N+1). The bulk variant fetches every matching template
// with children eager-loaded in a single round-trip.
var bundleTemplateNames = content.Templates.Select(t => t.Name);
var hydratedTemplates = await _templateRepo
.GetTemplatesWithChildrenAsync(bundleTemplateNames, ct)
.ConfigureAwait(false);
var hydratedByName = hydratedTemplates
.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
foreach (var tDto in content.Templates)
{
hydratedByName.TryGetValue(tDto.Name, out var existing);
items.Add(_diff.CompareTemplate(tDto, existing));
}
// ---- SharedScripts ----
foreach (var s in content.SharedScripts)
{
var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareSharedScript(s, existing));
}
// ---- ExternalSystems (+ their methods) ----
foreach (var es in content.ExternalSystems)
{
var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
IReadOnlyList<Commons.Entities.ExternalSystems.ExternalSystemMethod>? methods = null;
if (existing is not null)
{
methods = await _externalRepo.GetMethodsByExternalSystemIdAsync(existing.Id, ct).ConfigureAwait(false);
}
items.Add(_diff.CompareExternalSystem(es, existing, methods));
}
// ---- DatabaseConnections ----
foreach (var db in content.DatabaseConnections)
{
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareDatabaseConnection(db, existing));
}
// ---- NotificationLists ----
foreach (var nl in content.NotificationLists)
{
var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareNotificationList(nl, existing));
}
// ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
foreach (var sm in content.SmtpConfigs)
{
smtpByHost.TryGetValue(sm.Host, out var existing);
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
}
// ---- ApiKeys ----
// Inbound API keys are not transported between environments (re-arch C4).
// New bundles never carry a keys section. A pre-C4 bundle may still contain
// one; we do NOT surface those keys as importable preview rows (they would
// offer Add/Overwrite actions for keys that can't be meaningfully re-created
// from a hash). They are counted, ignored, and reported at apply time.
// ---- ApiMethods ----
foreach (var m in content.ApiMethods)
{
var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
items.Add(_diff.CompareApiMethod(m, existing));
}
// ---- M8 site/instance-scoped types ----
// Sites/DataConnections/Instances are referenced by stable string identity
// (SiteIdentifier / connection Name / UniqueName) and resolved against the
// TARGET environment's own surrogate keys. We auto-match the source site by
// identifier; the result drives both the per-type diffs (a matched target
// connection / instance feeds CompareDataConnection / CompareInstance) and
// the operator-facing required-mapping list built below.
//
// Cache target-site lookups + their data connections so we don't re-query
// the same site once per instance / connection / native-alarm override.
var targetSiteByIdentifier = new Dictionary<string, Site?>(StringComparer.Ordinal);
var targetConnectionsBySiteIdentifier =
new Dictionary<string, IReadOnlyList<DataConnection>>(StringComparer.Ordinal);
async Task<Site?> ResolveTargetSiteAsync(string siteIdentifier)
{
if (targetSiteByIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
var site = await _siteRepo.GetSiteByIdentifierAsync(siteIdentifier, ct).ConfigureAwait(false);
targetSiteByIdentifier[siteIdentifier] = site;
return site;
}
async Task<IReadOnlyList<DataConnection>> ResolveTargetConnectionsAsync(string siteIdentifier)
{
if (targetConnectionsBySiteIdentifier.TryGetValue(siteIdentifier, out var cached)) return cached;
var site = await ResolveTargetSiteAsync(siteIdentifier).ConfigureAwait(false);
IReadOnlyList<DataConnection> conns = site is null
? Array.Empty<DataConnection>()
: await _siteRepo.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false);
targetConnectionsBySiteIdentifier[siteIdentifier] = conns;
return conns;
}
// ---- Sites ----
foreach (var siteDto in content.Sites)
{
var existing = await ResolveTargetSiteAsync(siteDto.SiteIdentifier).ConfigureAwait(false);
items.Add(_diff.CompareSite(siteDto, existing));
}
// ---- DataConnections (site-scoped; matched by name within the auto-matched target site) ----
// C2: connection names are unique only WITHIN a site, so the preview item's
// identity is SITE-QUALIFIED (`{SiteIdentifier}/{Name}`). The diff CONTENT is
// unchanged — only the item Name is qualified (after CompareDataConnection
// returns) so two sites' same-named connections resolve to distinct items and
// the operator's per-item Skip/Overwrite applies to the right site's connection.
// ApplyDataConnectionsAsync looks the resolution up by this same qualified key.
foreach (var dcDto in content.DataConnections)
{
var targetConns = await ResolveTargetConnectionsAsync(dcDto.SiteIdentifier).ConfigureAwait(false);
var existing = targetConns.FirstOrDefault(c => string.Equals(c.Name, dcDto.Name, StringComparison.Ordinal));
var item = _diff.CompareDataConnection(dcDto, existing);
items.Add(item with { Name = QualifiedConnectionName(dcDto.SiteIdentifier, dcDto.Name) });
}
// ---- Instances (hydrated target + resolved template/site/area names; review item I2) ----
foreach (var instDto in content.Instances)
{
// GetInstanceByUniqueNameAsync eagerly Includes all four child nav
// collections (AttributeOverrides / AlarmOverrides / ConnectionBindings /
// NativeAlarmSourceOverrides), so the entity handed to CompareInstance is
// HYDRATED — its children diff correctly instead of every incoming child
// reading as an addition (review item I2).
var existing = await _templateRepo
.GetInstanceByUniqueNameAsync(instDto.UniqueName, ct).ConfigureAwait(false);
string? existingTemplateName = null;
string? existingSiteIdentifier = null;
string? existingAreaName = null;
if (existing is not null)
{
// The entity stores template/site/area as numeric FKs that can't be
// compared cross-environment, so resolve each to the same stable
// string identity the incoming DTO carries.
var tmpl = await _templateRepo.GetTemplateByIdAsync(existing.TemplateId, ct).ConfigureAwait(false);
existingTemplateName = tmpl?.Name;
var site = await _siteRepo.GetSiteByIdAsync(existing.SiteId, ct).ConfigureAwait(false);
existingSiteIdentifier = site?.SiteIdentifier;
if (existing.AreaId is int areaId)
{
var area = await _templateRepo.GetAreaByIdAsync(areaId, ct).ConfigureAwait(false);
existingAreaName = area?.Name;
}
}
items.Add(_diff.CompareInstance(
instDto, existing, existingTemplateName, existingSiteIdentifier, existingAreaName));
}
// ---- Required site/connection mappings (M8) ----
var (requiredSites, requiredConnections) = await BuildRequiredMappingsAsync(
content, ResolveTargetSiteAsync, ResolveTargetConnectionsAsync).ConfigureAwait(false);
// ---- Blocker detection ----
items.AddRange(await DetectBlockersAsync(content, ResolveTargetConnectionsAsync, ct).ConfigureAwait(false));
return new ImportPreview(sessionId, items, requiredSites, requiredConnections);
}
/// <summary>
/// Collects the distinct set of source sites and (site, connection) pairs the
/// bundle references, auto-matching each against the TARGET environment by
/// identity, and returns the operator-facing required-mapping lists (M8 C2).
/// <para>
/// Site references are drawn from every instance, every site, and every
/// data-connection in the bundle. Connection references are drawn from every
/// instance connection-binding, every non-null native-alarm-source
/// <c>ConnectionNameOverride</c>, and every bundled data-connection. A source
/// site auto-matches when the target has a site with the same identifier; a
/// connection auto-matches when that target site additionally carries a
/// connection of the same name. No match leaves <c>AutoMatchTarget*</c> null —
/// the operator must supply an explicit mapping (or accept create-new) at
/// apply time. The diff path above already populated the resolver caches, so
/// these lookups are served from memory.
/// </para>
/// </summary>
private static async Task<(IReadOnlyList<RequiredSiteMapping> Sites, IReadOnlyList<RequiredConnectionMapping> Connections)>
BuildRequiredMappingsAsync(
BundleContentDto content,
Func<string, Task<Site?>> resolveTargetSite,
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections)
{
// Distinct source-site identifiers, with a best-effort display name.
// SiteDto carries a Name; instances / data-connections only carry the
// identifier, so default that to the identifier when no SiteDto is present.
var siteNameByIdentifier = new Dictionary<string, string>(StringComparer.Ordinal);
void NoteSite(string identifier, string? name)
{
if (string.IsNullOrEmpty(identifier)) return;
if (!siteNameByIdentifier.TryGetValue(identifier, out var existing)
|| (string.Equals(existing, identifier, StringComparison.Ordinal) && !string.IsNullOrEmpty(name)))
{
siteNameByIdentifier[identifier] = string.IsNullOrEmpty(name) ? identifier : name;
}
}
foreach (var s in content.Sites) NoteSite(s.SiteIdentifier, s.Name);
foreach (var i in content.Instances) NoteSite(i.SiteIdentifier, null);
foreach (var dc in content.DataConnections) NoteSite(dc.SiteIdentifier, null);
// Distinct (sourceSite, connectionName) pairs referenced anywhere.
var connectionRefs = new HashSet<(string Site, string Name)>();
foreach (var i in content.Instances)
{
NoteSite(i.SiteIdentifier, null);
foreach (var b in i.ConnectionBindings)
{
if (!string.IsNullOrEmpty(b.ConnectionName))
{
connectionRefs.Add((i.SiteIdentifier, b.ConnectionName));
}
}
foreach (var n in i.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(n.ConnectionNameOverride))
{
connectionRefs.Add((i.SiteIdentifier, n.ConnectionNameOverride));
}
}
}
foreach (var dc in content.DataConnections)
{
if (!string.IsNullOrEmpty(dc.Name))
{
connectionRefs.Add((dc.SiteIdentifier, dc.Name));
}
}
var siteMappings = new List<RequiredSiteMapping>();
foreach (var identifier in siteNameByIdentifier.Keys.OrderBy(k => k, StringComparer.Ordinal))
{
var target = await resolveTargetSite(identifier).ConfigureAwait(false);
siteMappings.Add(new RequiredSiteMapping(
SourceSiteIdentifier: identifier,
SourceSiteName: siteNameByIdentifier[identifier],
AutoMatchTargetIdentifier: target?.SiteIdentifier));
}
var connectionMappings = new List<RequiredConnectionMapping>();
foreach (var (site, name) in connectionRefs
.OrderBy(r => r.Site, StringComparer.Ordinal)
.ThenBy(r => r.Name, StringComparer.Ordinal))
{
// Auto-match only WITHIN the auto-matched target site: a connection of
// the same name under a different site is not a valid match.
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
var matched = targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal));
connectionMappings.Add(new RequiredConnectionMapping(
SourceSiteIdentifier: site,
SourceConnectionName: name,
AutoMatchTargetName: matched ? name : null));
}
return (siteMappings, connectionMappings);
}
/// <summary>
/// Surfaces unresolved cross-entity references: a TemplateScript or
/// ApiMethod body that name-mentions a SharedScript or ExternalSystem that
/// is in neither the bundle nor the target database. We reuse the same
/// substring-with-word-boundary scan as <c>DependencyResolver</c>; the
/// implementations are kept in lockstep but not factored out yet because
/// the resolver's scan operates on entity Code while the importer's scan
/// operates on DTO Code — same algorithm, different inputs.
/// </summary>
private async Task<IReadOnlyList<ImportPreviewItem>> DetectBlockersAsync(
BundleContentDto content,
Func<string, Task<IReadOnlyList<DataConnection>>> resolveTargetConnections,
CancellationToken ct)
{
var blockers = new List<ImportPreviewItem>();
// ---- M8: instance template + referenced-connection blockers ----
// The set of template names the import can satisfy = (in-bundle templates)
// (templates already in the target DB). An instance whose TemplateName is
// in neither is unresolvable.
var bundleTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates) bundleTemplateNames.Add(t.Name);
// Honour a rename: a bundled template resolved as Rename is created under
// its new name, but at preview time no resolution has been chosen yet, so
// the in-bundle name is the original DTO name — which is what an instance
// references. (Explicit rename remap is a D-wave apply-time concern.)
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
{
targetTemplateNames.Add(t.Name);
}
foreach (var inst in content.Instances)
{
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
if (bundleTemplateNames.Contains(inst.TemplateName)) continue;
if (targetTemplateNames.Contains(inst.TemplateName)) continue;
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
Name: inst.UniqueName,
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: $"Template '{inst.TemplateName}' not found in bundle or target."));
}
// A referenced (sourceSite, connectionName) pair is resolvable when it is
// either carried in the bundle's DataConnections OR auto-matches a connection
// of the same name in the auto-matched target site. Genuinely-missing
// references are blockers. (Explicit operator connection maps are applied in
// a later wave; preview's auto-match is identity-based.)
var bundleConnections = new HashSet<(string Site, string Name)>();
foreach (var dc in content.DataConnections)
{
if (!string.IsNullOrEmpty(dc.Name)) bundleConnections.Add((dc.SiteIdentifier, dc.Name));
}
// Distinct referenced pairs from instance bindings + native-alarm overrides.
var referencedConnections = new HashSet<(string Site, string Name)>();
foreach (var inst in content.Instances)
{
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
.OrderBy(r => r.Site, StringComparer.Ordinal)
.ThenBy(r => r.Name, StringComparer.Ordinal))
{
if (bundleConnections.Contains((site, name))) continue;
var targetConns = await resolveTargetConnections(site).ConfigureAwait(false);
if (targetConns.Any(c => string.Equals(c.Name, name, StringComparison.Ordinal))) continue;
// C2: site-qualify the blocker Name so two sites' same-named-but-missing
// connections surface as distinct blockers (a bare name would collide).
blockers.Add(new ImportPreviewItem(
EntityType: "Instance",
Name: QualifiedConnectionName(site, name),
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: $"Connection '{site}/{name}' unresolved — present in neither bundle nor target."));
}
// Known-resolvable names = (in-bundle) (already-in-target).
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
foreach (var s in allSharedScripts) sharedScriptNames.Add(s.Name);
var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var e in content.ExternalSystems) externalSystemNames.Add(e.Name);
foreach (var e in allExternalSystems) externalSystemNames.Add(e.Name);
// Heuristic: collect a small candidate vocabulary of identifiers used
// by the bundle's scripts that are NOT one of the known-good names, and
// check whether each one was previously a SharedScript or ExternalSystem
// (i.e. matches the naming-convention shape of an identifier reference).
// For v1, we walk the SharedScripts the bundle *expects* — by scanning
// bodies and reporting any identifier-shaped token that resolves to a
// SharedScript by historical record... but that's overreach.
//
// Simpler + sufficient v1: scan every script body in the bundle's
// templates + ApiMethods, and for each occurrence of "Name(" where
// Name is a valid identifier, if Name appears in NEITHER set, surface
// it as a Blocker. This catches the documented use-case
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
var referencedFromBundle = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
foreach (var a in t.Attributes)
{
// Attribute.Value carries the design-time default expression, which
// can be script-callable. DataSourceReference is an OPC UA node
// address path (e.g. "ns=3;s=Tank.Level") owned by the device --
// it's never script source and must NOT be scanned, or the dot
// delimiter trips the heuristic into flagging the address segments
// as missing SharedScript/ExternalSystem references.
CollectCallIdentifiers(a.Value, referencedFromBundle);
}
}
foreach (var m in content.ApiMethods)
{
CollectCallIdentifiers(m.Script, referencedFromBundle);
}
// For each candidate, only report it as a blocker if it looks like a
// resource reference (PascalCase, length > 1), isn't a well-known
// language / runtime / SQL token, AND isn't present anywhere we can
// satisfy it. The denylist is the noise filter that keeps the
// heuristic usable on real script bodies — without it, every member
// access (`obj.ToString()`) and stdlib type (`DateTimeOffset`) gets
// flagged.
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
var isShared = sharedScriptNames.Contains(candidate);
var isExternal = externalSystemNames.Contains(candidate);
if (isShared || isExternal) continue;
blockers.Add(new ImportPreviewItem(
EntityType: "Reference",
Name: candidate,
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
}
return blockers;
}
private static void CollectCallIdentifiers(string? body, HashSet<string> sink)
{
if (string.IsNullOrEmpty(body)) return;
// Find every "Identifier(" or "Identifier." occurrence. The boundary
// before the identifier must NOT be an identifier char so we don't
// match the trailing portion of a longer token, AND must not be a
// dot — otherwise `obj.Method()` would flag `Method` as a top-level
// reference. Member-access trailing identifiers are skipped.
for (var i = 0; i < body.Length; i++)
{
if (!IsIdentifierStart(body[i])) continue;
if (i > 0 && (IsIdentifierChar(body[i - 1]) || body[i - 1] == '.')) continue;
var start = i;
while (i < body.Length && IsIdentifierChar(body[i])) i++;
if (i >= body.Length) break;
var trailing = body[i];
if (trailing == '(' || trailing == '.')
{
sink.Add(body[start..i]);
}
}
}
/// <summary>
/// Names that look like PascalCase references but are never user-defined
/// SharedScripts or ExternalSystems. Filters the false-positive noise the
/// identifier scan produces against real script bodies: .NET stdlib types
/// and helpers, ScadaBridge runtime API roots, and common SQL keywords that
/// appear inside string literals. Match is case-sensitive (Ordinal).
/// </summary>
private static readonly HashSet<string> KnownNonReferenceNames = new(StringComparer.Ordinal)
{
// .NET / C# stdlib
"Boolean", "Byte", "Char", "Console", "Convert", "DateTime",
"DateTimeOffset", "Decimal", "Dispose", "Double", "Enumerable",
"Exception", "Guid", "Int16", "Int32", "Int64", "List", "Math",
"Now", "Object", "Single", "String", "Task", "TimeSpan", "ToBoolean",
"ToDateTime", "ToDecimal", "ToDouble", "ToInt16", "ToInt32", "ToInt64",
"ToList", "ToSingle", "ToString", "UtcNow",
// ScadaBridge script runtime API roots and well-known members
"Attribute", "Attributes", "Call", "CallScript", "CallShared",
"Connection", "CreateCommand", "Database", "ExecuteAsync",
"ExecuteNonQueryAsync", "ExecuteReaderAsync", "ExecuteScalarAsync",
"ExternalSystem", "GetAsync", "GetAttribute", "Instance", "Notify",
"Parameters", "Request", "Response", "Route", "Scheduler", "Scripts",
"Send", "SetAsync", "SetAttribute",
// SQL keywords commonly seen inside string literals
"COUNT", "FROM", "GROUP", "INSERT", "JOIN", "ORDER", "SELECT",
"UPDATE", "WHERE",
};
private static bool LooksLikeResourceName(string name)
{
if (name.Length < 2) return false;
if (!char.IsUpper(name[0])) return false;
for (var i = 1; i < name.Length; i++)
{
if (!IsIdentifierChar(name[i])) return false;
}
return true;
}
private static bool IsIdentifierStart(char c) => c == '_' || char.IsLetter(c);
private static bool IsIdentifierChar(char c) => c == '_' || char.IsLetterOrDigit(c);
/// <summary>
/// C2: the site-qualified identity (<c>{siteIdentifier}/{connectionName}</c>) used
/// for every <c>DataConnection</c> preview item Name + blocker Name, and for the
/// matching resolution lookup in <see cref="ApplyDataConnectionsAsync"/>. Connection
/// names are unique only WITHIN a site, so a bare-name key would collapse two sites'
/// same-named connections onto one resolution entry. Callers building a resolution
/// map from <c>preview.Items</c> keyed by <c>(EntityType, Name)</c> get the
/// site-qualified key for free.
/// </summary>
private static string QualifiedConnectionName(string siteIdentifier, string connectionName) =>
$"{siteIdentifier}/{connectionName}";
/// <inheritdoc />
public async Task<ImportResult> ApplyAsync(
Guid sessionId,
IReadOnlyList<ImportResolution> resolutions,
string user,
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)
{
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
}
BundleContentDto content;
try
{
content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
?? throw new InvalidDataException("Session content deserialized to null.");
}
catch (JsonException ex)
{
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
}
var bundleImportId = Guid.NewGuid();
var resolutionMap = resolutions.ToDictionary(
r => (r.EntityType, r.Name),
r => r);
var summary = new ImportSummary();
// Inbound API keys are not transported between environments (re-arch C4).
// A pre-C4 bundle may still contain a keys section; we ignore those keys
// entirely (never re-create them) but count them so the result can tell
// the operator to re-issue keys on this environment.
var apiKeysIgnored = content.ApiKeys?.Count ?? 0;
// Set the correlation BEFORE the transaction so any audit writes
// triggered during the apply pick up the BundleImportId — AuditService
// reads the scoped context at the moment LogAsync is called.
_correlationContext.BundleImportId = bundleImportId;
// BeginTransactionAsync is a no-op on the in-memory EF provider (which
// logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep
// rollback semantics testable on in-memory AND correct on relational
// providers, we defer the SINGLE SaveChangesAsync call until just before
// CommitAsync — every Add*Async + LogAsync call only stages on the
// change tracker, so throwing before SaveChangesAsync naturally undoes
// the entire apply on both providers.
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
try
{
// Run semantic validation FIRST — before any writes are staged.
// This is purely a name-resolution scan over the in-memory DTOs +
// pre-existing target reads, so it has no ordering dependency on
// the Apply* helpers. Failing here means the change tracker is
// still empty, which keeps the rollback contract simple on both
// the in-memory and relational providers (FU-B: intermediate
// SaveChangesAsync between Apply* and the second-pass rewire
// would otherwise prevent the in-memory provider from undoing
// already-flushed template rows on validation failure — the
// in-memory transaction is a no-op, so the only safe pattern is
// ONE deferred SaveChangesAsync at the very end of try-block).
//
// Skip-resolved DTOs are excluded from the in-bundle name set so
// 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);
await ApplyExternalSystemsAsync(content.ExternalSystems, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyDatabaseConnectionsAsync(content.DatabaseConnections, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplyNotificationListsAsync(content.NotificationLists, resolutionMap, user, summary, ct).ConfigureAwait(false);
await ApplySmtpConfigsAsync(content.SmtpConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
// Inbound API keys are NOT applied from a bundle (re-arch C4) — any keys
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
// FKs that can only be resolved AFTER every template's scripts and
// child rows have been staged. We flush here so Pass A can look up
// the just-persisted scripts by name and Pass B can look up the
// just-persisted templates by name; both passes stage further
// mutations that ride the SAME outer transaction (committed below).
//
// EF tracking note: AddAsync on the in-memory provider assigns
// synthetic ids eagerly, so this intermediate flush mostly
// materialises identity values on a relational provider. The
// rollback contract is preserved because semantic validation
// already ran above — any throw from this point onward represents
// a successful merge that the user wants to keep, and the only
// remaining failure surface is the final BundleImported audit
// write itself.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
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",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
session.Manifest.SourceEnvironment,
session.Manifest.ContentHash,
Summary = new
{
summary.Added,
summary.Overwritten,
summary.Skipped,
summary.Renamed,
},
// re-arch C4: legacy inbound API keys present in the bundle that
// were ignored (not transported / not re-created here).
ApiKeysIgnored = apiKeysIgnored,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
await tx.CommitAsync(ct).ConfigureAwait(false);
// T-007: zero out the decrypted plaintext BEFORE remove so any
// caller-held reference (e.g. the Razor page that built the
// ImportPreview) sees the cleared buffer too. Remove drops the
// dictionary entry; together they release the secrets immediately
// instead of leaving them in process memory for the full TTL.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
return new ImportResult(
BundleImportId: bundleImportId,
Added: summary.Added,
Overwritten: summary.Overwritten,
Skipped: summary.Skipped,
Renamed: summary.Renamed,
StaleInstanceIds: Array.Empty<int>(),
AuditEventCorrelation: bundleImportId.ToString(),
ApiKeysIgnored: apiKeysIgnored);
}
catch (Exception ex)
{
// Rollback can itself throw (connection drop mid-rollback, provider
// bug, etc). If it does, we must STILL write the BundleImportFailed
// audit row — otherwise a rollback-failure path silently swallows
// the import's audit trail. Capture the rollback exception (if any)
// and surface it on the failure row alongside the original cause.
Exception? rollbackFailure = null;
try
{
await tx.RollbackAsync(ct).ConfigureAwait(false);
}
catch (Exception rbEx)
{
rollbackFailure = rbEx;
}
// If rollback threw the IDbContextTransaction is in an indeterminate
// state and still associated with the DbContext — a subsequent
// SaveChangesAsync would attempt to enlist in (or commit to) that
// broken transaction, and the failure-row would itself be rolled
// back when the transaction is finally disposed. Dispose it now so
// the audit-row write below uses a fresh implicit transaction. On
// the happy rollback path Dispose is a benign no-op (the using
// would call it on scope exit anyway).
if (rollbackFailure is not null)
{
try { await tx.DisposeAsync().ConfigureAwait(false); }
catch { /* dispose-after-throw must not mask the original cause */ }
}
// Clear the change tracker before writing the failure row — on the
// in-memory provider the rollback is a no-op and the staged adds
// would otherwise persist when the next SaveChangesAsync runs. This
// also matters when rollback threw: the change tracker is in an
// ambiguous state and we don't want the failure-write to sweep up
// any of the staged apply mutations.
_dbContext.ChangeTracker.Clear();
// Clear correlation FIRST so the failure row doesn't carry the now-
// rolled-back BundleImportId. The contract is: BundleImportFailed
// exists at top level (no correlation) so audit consumers can see
// imports that aborted before any rows landed.
_correlationContext.BundleImportId = null;
try
{
await _auditService.LogAsync(
user: user,
action: "BundleImportFailed",
entityType: "Bundle",
entityId: bundleImportId.ToString(),
entityName: session.Manifest.SourceEnvironment,
afterState: new
{
BundleImportId = bundleImportId,
Reason = ex.Message,
ExceptionType = ex.GetType().FullName,
RollbackException = rollbackFailure?.Message,
},
cancellationToken: ct).ConfigureAwait(false);
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
}
catch
{
// Audit-write is best-effort per design §10 ("Audit-write failure
// NEVER aborts the user-facing action — audit is best-effort, the
// action's own success/failure path is authoritative"). Swallow
// any failure here so the original exception below propagates
// unchanged rather than being masked by an audit-layer fault.
}
// T-007: a failed apply used to leave the BundleSession (with its
// decrypted secrets) in the in-memory store for the full 30-minute
// TTL — 10 failed 100 MB imports = 1 GB of plaintext still rooted.
// Drop the session here too so the secrets are released as soon as
// the failure surfaces, not when the next Get() happens to evict.
ZeroDecryptedContent(session);
_sessionStore.Remove(sessionId);
throw;
}
finally
{
// Always clear — even on the success path the correlation only
// applies to the apply we just finished. Subsequent operations on
// this scope (e.g. a second concurrent apply on a circuit) must
// not inherit the import id.
_correlationContext.BundleImportId = null;
}
}
/// <summary>
/// T-007: zeros the session's <see cref="BundleSession.DecryptedContent"/>
/// buffer in place so any caller still holding a reference observes the
/// cleared bytes. Best-effort — a null/empty buffer is a no-op.
/// </summary>
private static void ZeroDecryptedContent(BundleSession session)
{
var buf = session.DecryptedContent;
if (buf is { Length: > 0 })
{
Array.Clear(buf, 0, buf.Length);
}
}
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
private sealed class ImportSummary
{
/// <summary>Number of artifacts inserted as new entries.</summary>
public int Added { get; set; }
/// <summary>Number of artifacts overwritten with the bundle version.</summary>
public int Overwritten { get; set; }
/// <summary>Number of artifacts skipped (left unchanged).</summary>
public int Skipped { get; set; }
/// <summary>Number of artifacts renamed to avoid a collision.</summary>
public int Renamed { get; set; }
}
/// <summary>
/// Returns the resolution for the given (entityType, name) tuple, defaulting to
/// <see cref="ResolutionAction.Add"/> when no explicit resolution was supplied —
/// the diff engine surfaces every artifact in the preview so any missing
/// entry means the UI didn't override the default, which for a New artifact
/// is Add. This keeps the apply tolerant of partial-resolution payloads.
/// </summary>
private static ImportResolution ResolveOrDefault(
Dictionary<(string EntityType, string Name), ImportResolution> map,
string entityType,
string name)
{
return map.TryGetValue((entityType, name), out var r)
? r
: new ImportResolution(entityType, name, ResolutionAction.Add, RenameTo: null);
}
private async Task ApplyTemplateFoldersAsync(
IReadOnlyList<TemplateFolderDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var existing = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var byName = existing.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var folder = new TemplateFolder(name) { SortOrder = dto.SortOrder };
await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", name,
new { folder.Name, folder.SortOrder, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.SortOrder = dto.SortOrder;
await _templateRepo.UpdateFolderAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "TemplateFolder", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.SortOrder }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var folder = new TemplateFolder(dto.Name) { SortOrder = dto.SortOrder };
await _templateRepo.AddFolderAsync(folder, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "TemplateFolder", "0", folder.Name,
new { folder.Name, folder.SortOrder }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyTemplatesAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var stubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
var byName = stubs.ToDictionary(t => t.Name, t => t, StringComparer.Ordinal);
// Folder ids must be materialised before we wire templates' FolderId
// FKs. ApplyTemplateFoldersAsync staged the rows via AddFolderAsync
// but did NOT call SaveChanges -- on a relational provider that means
// every new folder still has Id=0 in the change tracker. Flushing
// here is safe: we're inside the outer import transaction (begun in
// ApplyAsync), so any later throw still rolls everything back as one
// unit. The cost is one extra round-trip per import, negligible.
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
var folders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
var folderIdByName = folders.ToDictionary(f => f.Name, f => f.Id, StringComparer.Ordinal);
// Honour folder renames -- a TemplateFolder resolved as Rename was
// written under its new name; templates that reference the old name in
// the bundle DTO must map to the renamed folder, not be orphaned.
int? ResolveFolderId(string? folderName)
{
if (folderName is null) return null;
var folderResolution = ResolveOrDefault(map, "TemplateFolder", folderName);
var lookupName = (folderResolution.Action == ResolutionAction.Rename
&& !string.IsNullOrEmpty(folderResolution.RenameTo))
? folderResolution.RenameTo
: folderName;
return folderIdByName.TryGetValue(lookupName, out var fid) ? fid : (int?)null;
}
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "Template", dto.Name);
var folderId = ResolveFolderId(dto.FolderName);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var t = BuildTemplate(dto, overrideName: name);
t.FolderId = folderId;
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Template", "0", name,
new { Name = name, dto.Description, FolderId = folderId, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
ex.Description = dto.Description;
ex.FolderId = folderId;
await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
new { ex.Name, ex.Description, ex.FolderId }, ct).ConfigureAwait(false);
// T-001: Overwrite must also synchronise child collections —
// attributes / alarms / scripts diverging between the bundle
// and the target must round-trip. Composition rewire is
// handled by ResolveCompositionEdgesAsync after the global
// flush; alarm→script FKs are rewired by
// ResolveAlarmScriptLinksAsync. The helpers below stage the
// child diffs (add / update / delete) onto the tracked
// entity and emit one audit row per detected change.
await SyncTemplateAttributesAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateAlarmsAsync(ex, dto, user, ct).ConfigureAwait(false);
await SyncTemplateScriptsAsync(ex, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var t = BuildTemplate(dto, overrideName: null);
t.FolderId = folderId;
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "Template", "0", t.Name,
new { t.Name, t.Description, t.FolderId }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
/// <summary>
/// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
/// alarms / scripts. Two name-keyed FKs are NOT wired here because they
/// require post-flush identity values:
/// <list type="bullet">
/// <item><c>TemplateAlarm.OnTriggerScriptId</c> — points at a sibling
/// <c>TemplateScript</c>; resolved by <see cref="ResolveAlarmScriptLinksAsync"/>
/// once <c>SaveChangesAsync</c> has assigned script ids.</item>
/// <item><c>TemplateComposition.ComposedTemplateId</c> — points at any
/// other persisted <c>Template</c>; resolved by
/// <see cref="ResolveCompositionEdgesAsync"/> once all bundled templates
/// have been flushed and any pre-existing target templates can be joined
/// in by name.</item>
/// </list>
/// Both resolution passes run inside the same outer import transaction.
/// <paramref name="overrideName"/> supports the Rename resolution; pass
/// <c>null</c> to keep the DTO's original name. Renamed templates are
/// looked up by their <em>imported</em> name (i.e. <c>RenameTo</c>) when
/// the second pass resolves their alarm/composition FKs; however, bundle
/// DTOs that reference a renamed template by its <em>original</em> name
/// will still fall through to the unresolved-audit path — call sites are
/// not rewritten in v1.
/// </summary>
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
{
var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
foreach (var a in dto.Attributes)
{
t.Attributes.Add(new TemplateAttribute(a.Name)
{
Value = ImportValueNormalizer.NormalizeListValue(a.Value, a.DataType, a.ElementDataType),
DataType = a.DataType,
IsLocked = a.IsLocked,
Description = a.Description,
DataSourceReference = a.DataSourceReference,
ElementDataType = a.ElementDataType,
});
}
foreach (var al in dto.Alarms)
{
t.Alarms.Add(new TemplateAlarm(al.Name)
{
Description = al.Description,
PriorityLevel = al.PriorityLevel,
TriggerType = al.TriggerType,
TriggerConfiguration = al.TriggerConfiguration,
IsLocked = al.IsLocked,
});
}
foreach (var s in dto.Scripts)
{
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
{
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
IsLocked = s.IsLocked,
});
}
return t;
}
/// <summary>
/// T-001 — Overwrite child sync (attributes). Diffs the DTO's
/// <c>Attributes</c> against the existing template's attribute collection
/// by name and stages add / update / delete on the tracked entity. Emits
/// one <c>TemplateAttributeAdded</c> / <c>TemplateAttributeUpdated</c> /
/// <c>TemplateAttributeDeleted</c> audit row per detected change — the
/// per-field rows the design doc's Configuration Audit Trail table
/// enumerates for the "Template overwritten" action.
/// <para>
/// Update detection compares every scalar field (Value, DataType,
/// ElementDataType, IsLocked, Description, DataSourceReference) — no field
/// change → no audit row, so an idempotent overwrite produces no noise.
/// </para>
/// </summary>
private async Task SyncTemplateAttributesAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Attributes.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
// Deletes — attributes present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAttributeAsync(existing.Id, ct).ConfigureAwait(false);
ex.Attributes.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAttributeDeleted",
"TemplateAttribute",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AttributeName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var attrDto in dto.Attributes)
{
// Normalise List values to the native-typed JSON form on import so the
// comparison (and the persisted value) match what the target already
// stores natively — otherwise an idempotent re-import of an old-form
// bundle would spuriously report a Value change.
var normalizedValue = ImportValueNormalizer.NormalizeListValue(
attrDto.Value, attrDto.DataType, attrDto.ElementDataType, _logger, attrDto.Name);
if (existingByName.TryGetValue(attrDto.Name, out var current))
{
// Update only if any field actually changed.
bool changed =
!string.Equals(current.Value, normalizedValue, StringComparison.Ordinal) ||
current.DataType != attrDto.DataType ||
current.IsLocked != attrDto.IsLocked ||
!string.Equals(current.Description, attrDto.Description, StringComparison.Ordinal) ||
!string.Equals(current.DataSourceReference, attrDto.DataSourceReference, StringComparison.Ordinal) ||
current.ElementDataType != attrDto.ElementDataType;
if (!changed) continue;
current.Value = normalizedValue;
current.DataType = attrDto.DataType;
current.IsLocked = attrDto.IsLocked;
current.Description = attrDto.Description;
current.DataSourceReference = attrDto.DataSourceReference;
current.ElementDataType = attrDto.ElementDataType;
await _templateRepo.UpdateTemplateAttributeAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAttributeUpdated",
"TemplateAttribute",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AttributeName = current.Name,
current.Value,
current.DataType,
current.ElementDataType,
current.IsLocked,
current.Description,
current.DataSourceReference,
},
ct).ConfigureAwait(false);
}
else
{
var newAttr = new TemplateAttribute(attrDto.Name)
{
Value = normalizedValue,
DataType = attrDto.DataType,
IsLocked = attrDto.IsLocked,
Description = attrDto.Description,
DataSourceReference = attrDto.DataSourceReference,
ElementDataType = attrDto.ElementDataType,
};
ex.Attributes.Add(newAttr);
await _auditService.LogAsync(
user,
"TemplateAttributeAdded",
"TemplateAttribute",
"0",
$"{ex.Name}.{newAttr.Name}",
new
{
TemplateName = ex.Name,
AttributeName = newAttr.Name,
newAttr.Value,
newAttr.DataType,
newAttr.ElementDataType,
newAttr.IsLocked,
newAttr.Description,
newAttr.DataSourceReference,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// T-001 — Overwrite child sync (alarms). Mirrors
/// <see cref="SyncTemplateAttributesAsync"/> for the alarm collection.
/// Updated / added alarms have their <c>OnTriggerScriptId</c> cleared so
/// the post-flush <see cref="ResolveAlarmScriptLinksAsync"/> pass re-binds
/// the FK from the DTO's <c>OnTriggerScriptName</c> against the synced
/// script collection. Audit rows: <c>TemplateAlarmAdded</c> /
/// <c>TemplateAlarmUpdated</c> / <c>TemplateAlarmDeleted</c>.
/// </summary>
private async Task SyncTemplateAlarmsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
var dtoByName = dto.Alarms.ToDictionary(a => a.Name, a => a, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateAlarmAsync(existing.Id, ct).ConfigureAwait(false);
ex.Alarms.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateAlarmDeleted",
"TemplateAlarm",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, AlarmName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var alarmDto in dto.Alarms)
{
if (existingByName.TryGetValue(alarmDto.Name, out var current))
{
bool changed =
!string.Equals(current.Description, alarmDto.Description, StringComparison.Ordinal) ||
current.PriorityLevel != alarmDto.PriorityLevel ||
current.TriggerType != alarmDto.TriggerType ||
!string.Equals(current.TriggerConfiguration, alarmDto.TriggerConfiguration, StringComparison.Ordinal) ||
current.IsLocked != alarmDto.IsLocked;
if (!changed)
{
// Always reset the script FK on Overwrite so the post-flush
// resolve pass owns the binding (the DTO's script name is
// the authoritative reference); leaving a stale FK would
// silently survive Overwrite when the user expected the
// bundle to be the source of truth.
if ((current.OnTriggerScriptId is not null) ||
!string.IsNullOrEmpty(alarmDto.OnTriggerScriptName))
{
current.OnTriggerScriptId = null;
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
}
continue;
}
current.Description = alarmDto.Description;
current.PriorityLevel = alarmDto.PriorityLevel;
current.TriggerType = alarmDto.TriggerType;
current.TriggerConfiguration = alarmDto.TriggerConfiguration;
current.IsLocked = alarmDto.IsLocked;
current.OnTriggerScriptId = null; // re-resolved post-flush.
await _templateRepo.UpdateTemplateAlarmAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateAlarmUpdated",
"TemplateAlarm",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
AlarmName = current.Name,
current.Description,
current.PriorityLevel,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
else
{
var newAlarm = new TemplateAlarm(alarmDto.Name)
{
Description = alarmDto.Description,
PriorityLevel = alarmDto.PriorityLevel,
TriggerType = alarmDto.TriggerType,
TriggerConfiguration = alarmDto.TriggerConfiguration,
IsLocked = alarmDto.IsLocked,
};
ex.Alarms.Add(newAlarm);
await _auditService.LogAsync(
user,
"TemplateAlarmAdded",
"TemplateAlarm",
"0",
$"{ex.Name}.{newAlarm.Name}",
new
{
TemplateName = ex.Name,
AlarmName = newAlarm.Name,
newAlarm.Description,
newAlarm.PriorityLevel,
newAlarm.TriggerType,
newAlarm.TriggerConfiguration,
newAlarm.IsLocked,
OnTriggerScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// T-001 — Overwrite child sync (scripts). Mirrors
/// <see cref="SyncTemplateAttributesAsync"/> for the script collection.
/// Audit rows: <c>TemplateScriptAdded</c> / <c>TemplateScriptUpdated</c> /
/// <c>TemplateScriptDeleted</c>.
/// </summary>
private async Task SyncTemplateScriptsAsync(
Template ex,
TemplateDto dto,
string user,
CancellationToken ct)
{
var existingByName = ex.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
var dtoByName = dto.Scripts.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _templateRepo.DeleteTemplateScriptAsync(existing.Id, ct).ConfigureAwait(false);
ex.Scripts.Remove(existing);
await _auditService.LogAsync(
user,
"TemplateScriptDeleted",
"TemplateScript",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { TemplateName = ex.Name, ScriptName = existing.Name },
ct).ConfigureAwait(false);
}
foreach (var scriptDto in dto.Scripts)
{
if (existingByName.TryGetValue(scriptDto.Name, out var current))
{
bool changed =
!string.Equals(current.Code, scriptDto.Code, StringComparison.Ordinal) ||
!string.Equals(current.TriggerType, scriptDto.TriggerType, StringComparison.Ordinal) ||
!string.Equals(current.TriggerConfiguration, scriptDto.TriggerConfiguration, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, scriptDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, scriptDto.ReturnDefinition, StringComparison.Ordinal) ||
current.IsLocked != scriptDto.IsLocked;
if (!changed) continue;
current.Code = scriptDto.Code;
current.TriggerType = scriptDto.TriggerType;
current.TriggerConfiguration = scriptDto.TriggerConfiguration;
current.ParameterDefinitions = scriptDto.ParameterDefinitions;
current.ReturnDefinition = scriptDto.ReturnDefinition;
current.IsLocked = scriptDto.IsLocked;
await _templateRepo.UpdateTemplateScriptAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"TemplateScriptUpdated",
"TemplateScript",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
TemplateName = ex.Name,
ScriptName = current.Name,
current.TriggerType,
current.TriggerConfiguration,
current.IsLocked,
},
ct).ConfigureAwait(false);
}
else
{
var newScript = new TemplateScript(scriptDto.Name, scriptDto.Code)
{
TriggerType = scriptDto.TriggerType,
TriggerConfiguration = scriptDto.TriggerConfiguration,
ParameterDefinitions = scriptDto.ParameterDefinitions,
ReturnDefinition = scriptDto.ReturnDefinition,
IsLocked = scriptDto.IsLocked,
};
ex.Scripts.Add(newScript);
await _auditService.LogAsync(
user,
"TemplateScriptAdded",
"TemplateScript",
"0",
$"{ex.Name}.{newScript.Name}",
new
{
TemplateName = ex.Name,
ScriptName = newScript.Name,
newScript.TriggerType,
newScript.TriggerConfiguration,
newScript.IsLocked,
},
ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
/// carries any alarm with <c>OnTriggerScriptName</c>, look up the
/// persisted alarm + script by name on the same template and set the FK.
/// If the named script is missing from the imported template, leave the
/// FK null and emit a <c>BundleImportAlarmScriptUnresolved</c> audit row
/// (correlation context still carries <c>BundleImportId</c>).
/// </summary>
private async Task ResolveAlarmScriptLinksAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
// Resolve the imported template by its post-rename name. Skip-
// resolved DTOs were already filtered out above; everything else
// landed under either the DTO name or RenameTo.
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
// Use the children-loaded variant so the alarm + script collections
// are populated — the unfiltered GetAllTemplatesAsync returns the
// tracked entities EF assigned ids to during the flush, but the
// navigation collections aren't necessarily included.
var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (stub is null) continue;
var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
if (template is null) continue;
foreach (var alarmDto in dto.Alarms)
{
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
var alarm = template.Alarms.FirstOrDefault(a =>
string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
if (alarm is null) continue;
var script = template.Scripts.FirstOrDefault(s =>
string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
if (script is null)
{
// Unresolved — emit warning audit row, leave FK null.
await _auditService.LogAsync(
user,
"BundleImportAlarmScriptUnresolved",
"TemplateAlarm",
alarm.Id.ToString(),
$"{template.Name}.{alarm.Name}",
new
{
TemplateName = template.Name,
AlarmName = alarm.Name,
UnresolvedScriptName = alarmDto.OnTriggerScriptName,
},
ct).ConfigureAwait(false);
continue;
}
alarm.OnTriggerScriptId = script.Id;
await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
}
}
}
/// <summary>
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
/// any <c>Compositions</c>, replace the persisted template's existing
/// composition rows with new ones whose <c>ComposedTemplateId</c> is
/// resolved from <c>ComposedTemplateName</c> by looking up the now-
/// persisted template (just-imported set first, then pre-existing target).
/// <para>
/// Overwrite semantics: the persisted template's existing composition
/// rows are CLEARED before re-adding from the bundle so an Overwrite
/// truly overwrites the composition graph and doesn't silently retain
/// stale edges that aren't in the bundle anymore.
/// </para>
/// <para>
/// When <c>ComposedTemplateName</c> cannot be resolved — most commonly
/// because the user chose Skip on the referenced template — we emit a
/// <c>BundleImportCompositionUnresolved</c> audit row and skip the edge.
/// We deliberately do NOT throw: a Skip-resolved dependency is a
/// legitimate operator choice, not an import-killing error.
/// </para>
/// </summary>
private async Task ResolveCompositionEdgesAsync(
IReadOnlyList<TemplateDto> dtos,
Dictionary<(string, string), ImportResolution> resolutionMap,
string user,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
if (dto.Compositions.Count == 0) continue;
var importedName = resolution.Action == ResolutionAction.Rename
? (resolution.RenameTo ?? dto.Name)
: dto.Name;
var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, importedName, StringComparison.Ordinal));
if (parentStub is null) continue;
var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
if (parent is null) continue;
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
// the deletes via the navigation; explicit DeleteAsync ensures the
// change is staged even when the relational provider doesn't
// detect orphans automatically.
if (parent.Compositions.Count > 0)
{
var existingComps = parent.Compositions.ToList();
foreach (var existing in existingComps)
{
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
}
parent.Compositions.Clear();
}
foreach (var compDto in dto.Compositions)
{
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = (string?)null,
Reason = "Composition DTO carried empty ComposedTemplateName.",
},
ct).ConfigureAwait(false);
continue;
}
// Resolve the composed template by name — first against the
// tracked Local set (which includes anything just imported,
// pre-flush or post-flush), then against the wider target DB
// (pre-existing rows not staged in this transaction).
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
int? composedId = composedStub?.Id;
if (composedId is null)
{
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
composedId = allTargets
.FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
}
if (composedId is null)
{
await _auditService.LogAsync(
user,
"BundleImportCompositionUnresolved",
"TemplateComposition",
"0",
$"{parent.Name}.{compDto.InstanceName}",
new
{
OwnerTemplateName = parent.Name,
compDto.InstanceName,
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
Reason = "Composed template name not present in bundle or target.",
},
ct).ConfigureAwait(false);
continue;
}
var comp = new TemplateComposition(compDto.InstanceName)
{
TemplateId = parent.Id,
ComposedTemplateId = composedId.Value,
};
await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
}
}
}
private async Task ApplySharedScriptsAsync(
IReadOnlyList<SharedScriptDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "SharedScript", dto.Name);
var existing = await _templateRepo.GetSharedScriptByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var s = new SharedScript(name, dto.Code)
{
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
};
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name,
new { s.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Code = dto.Code;
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
await _templateRepo.UpdateSharedScriptAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "SharedScript", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var s = new SharedScript(dto.Name, dto.Code)
{
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
};
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SharedScript", "0", s.Name,
new { s.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyExternalSystemsAsync(
IReadOnlyList<ExternalSystemDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
var existing = await _externalRepo.GetExternalSystemByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var sys = BuildExternalSystem(dto, overrideName: name);
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", name,
new { sys.Name, sys.EndpointUrl, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.EndpointUrl = dto.BaseUrl;
existing.AuthType = dto.AuthType;
existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
// T-002: Overwrite must also synchronise the Methods child
// collection — added / removed / modified methods on the
// bundle DTO must round-trip. Mirrors the T-001 template
// child-sync helpers (attributes / alarms / scripts): each
// helper emits one audit row per detected change.
await SyncExternalSystemMethodsAsync(existing, dto, user, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var sys = BuildExternalSystem(dto, overrideName: null);
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", sys.Name,
new { sys.Name, sys.EndpointUrl }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static ExternalSystemDefinition BuildExternalSystem(ExternalSystemDto dto, string? overrideName)
{
var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
{
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
};
return sys;
}
/// <summary>
/// T-002 — Overwrite child sync (ExternalSystem methods). Mirrors the
/// T-001 <c>SyncTemplate*Async</c> helpers: name-keyed diff between the
/// bundle DTO and the persisted children, then add / update / delete via
/// the repository with one audit row per detected change. Methods are
/// NOT a navigation on <see cref="ExternalSystemDefinition"/> (the FK
/// runs from <see cref="ExternalSystemMethod.ExternalSystemDefinitionId"/>
/// to the parent) so the helper drives the repo directly rather than
/// mutating a tracked collection like the template helpers do.
/// <para>
/// Audit rows: <c>ExternalSystemMethodAdded</c> /
/// <c>ExternalSystemMethodUpdated</c> / <c>ExternalSystemMethodDeleted</c>.
/// Idempotent: scalar-field comparison gates the Update audit row, so an
/// Overwrite against an already-matching method produces no noise.
/// </para>
/// </summary>
private async Task SyncExternalSystemMethodsAsync(
ExternalSystemDefinition ex,
ExternalSystemDto dto,
string user,
CancellationToken ct)
{
var existingMethods = await _externalRepo
.GetMethodsByExternalSystemIdAsync(ex.Id, ct)
.ConfigureAwait(false);
var existingByName = existingMethods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
var dtoByName = dto.Methods.ToDictionary(m => m.Name, m => m, StringComparer.Ordinal);
// Deletes — methods present on the target but not in the bundle.
foreach (var existing in existingByName.Values.ToList())
{
if (dtoByName.ContainsKey(existing.Name)) continue;
await _externalRepo.DeleteExternalSystemMethodAsync(existing.Id, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodDeleted",
"ExternalSystemMethod",
existing.Id.ToString(),
$"{ex.Name}.{existing.Name}",
new { ExternalSystemName = ex.Name, MethodName = existing.Name },
ct).ConfigureAwait(false);
}
// Adds + Updates.
foreach (var methodDto in dto.Methods)
{
if (existingByName.TryGetValue(methodDto.Name, out var current))
{
// Update only if any field actually changed — mirrors the
// ArtifactDiff.ExternalSystemMethodsEqual comparator.
bool changed =
!string.Equals(current.HttpMethod, methodDto.HttpMethod, StringComparison.Ordinal) ||
!string.Equals(current.Path, methodDto.Path, StringComparison.Ordinal) ||
!string.Equals(current.ParameterDefinitions, methodDto.ParameterDefinitions, StringComparison.Ordinal) ||
!string.Equals(current.ReturnDefinition, methodDto.ReturnDefinition, StringComparison.Ordinal);
if (!changed) continue;
current.HttpMethod = methodDto.HttpMethod;
current.Path = methodDto.Path;
current.ParameterDefinitions = methodDto.ParameterDefinitions;
current.ReturnDefinition = methodDto.ReturnDefinition;
await _externalRepo.UpdateExternalSystemMethodAsync(current, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodUpdated",
"ExternalSystemMethod",
current.Id.ToString(),
$"{ex.Name}.{current.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = current.Name,
current.HttpMethod,
current.Path,
current.ParameterDefinitions,
current.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
else
{
var newMethod = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
{
ExternalSystemDefinitionId = ex.Id,
ParameterDefinitions = methodDto.ParameterDefinitions,
ReturnDefinition = methodDto.ReturnDefinition,
};
await _externalRepo.AddExternalSystemMethodAsync(newMethod, ct).ConfigureAwait(false);
await _auditService.LogAsync(
user,
"ExternalSystemMethodAdded",
"ExternalSystemMethod",
"0",
$"{ex.Name}.{newMethod.Name}",
new
{
ExternalSystemName = ex.Name,
MethodName = newMethod.Name,
newMethod.HttpMethod,
newMethod.Path,
newMethod.ParameterDefinitions,
newMethod.ReturnDefinition,
},
ct).ConfigureAwait(false);
}
}
}
private async Task ApplyDatabaseConnectionsAsync(
IReadOnlyList<DatabaseConnectionDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "DatabaseConnection", dto.Name);
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(dto.Name, ct).ConfigureAwait(false);
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var c = new DatabaseConnectionDefinition(name, connStr)
{
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", name,
new { c.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.ConnectionString = connStr;
existing.MaxRetries = dto.MaxRetries;
existing.RetryDelay = dto.RetryDelay;
await _externalRepo.UpdateDatabaseConnectionAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "DatabaseConnection", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var c = new DatabaseConnectionDefinition(dto.Name, connStr)
{
MaxRetries = dto.MaxRetries,
RetryDelay = dto.RetryDelay,
};
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", c.Name,
new { c.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private async Task ApplyNotificationListsAsync(
IReadOnlyList<NotificationListDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "NotificationList", dto.Name);
var existing = await _notificationRepo.GetListByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var list = BuildNotificationList(dto, overrideName: name);
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "NotificationList", "0", name,
new { list.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Type = dto.Type;
// Recipient sync is structural — clear + re-add. The repo
// mutates the navigation collection, EF tracks the delete.
existing.Recipients.Clear();
foreach (var r in dto.Recipients)
{
existing.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
}
await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name,
new { existing.Name, RecipientCount = existing.Recipients.Count }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var list = BuildNotificationList(dto, overrideName: null);
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "NotificationList", "0", list.Name,
new { list.Name, RecipientCount = list.Recipients.Count }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static NotificationList BuildNotificationList(NotificationListDto dto, string? overrideName)
{
var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type };
foreach (var r in dto.Recipients)
{
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
}
return list;
}
private async Task ApplySmtpConfigsAsync(
IReadOnlyList<SmtpConfigDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
foreach (var dto in dtos)
{
// SmtpConfiguration is keyed by Host in the diff engine — mirror
// that here so a Rename targets Host, not an arbitrary "name".
var resolution = ResolveOrDefault(map, "SmtpConfiguration", dto.Host);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var host = resolution.RenameTo ?? dto.Host;
var smtp = BuildSmtp(dto, overrideHost: host);
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", host,
new { smtp.Host, RenamedFrom = dto.Host }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when byHost.TryGetValue(dto.Host, out var ex):
ApplySmtpFields(ex, dto);
await _notificationRepo.UpdateSmtpConfigurationAsync(ex, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "SmtpConfiguration", ex.Id.ToString(), ex.Host,
new { ex.Host }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var smtp = BuildSmtp(dto, overrideHost: null);
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", smtp.Host,
new { smtp.Host }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static SmtpConfiguration BuildSmtp(SmtpConfigDto dto, string? overrideHost)
{
var smtp = new SmtpConfiguration(overrideHost ?? dto.Host, dto.AuthType, dto.FromAddress);
ApplySmtpFields(smtp, dto);
return smtp;
}
private static void ApplySmtpFields(SmtpConfiguration target, SmtpConfigDto dto)
{
target.Port = dto.Port;
target.AuthType = dto.AuthType;
target.FromAddress = dto.FromAddress;
target.TlsMode = dto.TlsMode;
target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
target.MaxRetries = dto.MaxRetries;
target.RetryDelay = dto.RetryDelay;
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
}
// ApplyApiKeysAsync was removed in re-arch C4: inbound API keys are not
// transported between environments, so a bundle never re-creates keys. Any keys
// present in a legacy (pre-C4) bundle are counted and ignored in ApplyAsync.
private async Task ApplyApiMethodsAsync(
IReadOnlyList<ApiMethodDto> dtos,
Dictionary<(string, string), ImportResolution> map,
string user,
ImportSummary summary,
CancellationToken ct)
{
if (dtos.Count == 0) return;
foreach (var dto in dtos)
{
var resolution = ResolveOrDefault(map, "ApiMethod", dto.Name);
var existing = await _inboundApiRepo.GetMethodByNameAsync(dto.Name, ct).ConfigureAwait(false);
switch (resolution.Action)
{
case ResolutionAction.Skip:
summary.Skipped++;
break;
case ResolutionAction.Rename:
{
var name = resolution.RenameTo ?? dto.Name;
var m = BuildApiMethod(dto, overrideName: name);
await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiMethod", "0", name,
new { m.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
summary.Renamed++;
break;
}
case ResolutionAction.Overwrite when existing is not null:
existing.Script = dto.Script;
// Method→key scopes are not transported (re-arch C4) and the
// ApprovedApiKeyIds column was dropped with the SQL Server ApiKey
// entity (re-arch C5): scopes are re-granted per environment in the
// shared ZB.MOM.WW.Auth.ApiKeys store, never via an imported bundle.
existing.ParameterDefinitions = dto.ParameterDefinitions;
existing.ReturnDefinition = dto.ReturnDefinition;
existing.TimeoutSeconds = dto.TimeoutSeconds;
await _inboundApiRepo.UpdateApiMethodAsync(existing, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Update", "ApiMethod", existing.Id.ToString(), existing.Name,
new { existing.Name }, ct).ConfigureAwait(false);
summary.Overwritten++;
break;
case ResolutionAction.Add:
case ResolutionAction.Overwrite:
default:
{
var m = BuildApiMethod(dto, overrideName: null);
await _inboundApiRepo.AddApiMethodAsync(m, ct).ConfigureAwait(false);
await _auditService.LogAsync(user, "Create", "ApiMethod", "0", m.Name,
new { m.Name }, ct).ConfigureAwait(false);
summary.Added++;
break;
}
}
}
}
private static ApiMethod BuildApiMethod(ApiMethodDto dto, string? overrideName)
{
// Method→key scopes are not transported (re-arch C4); the ApprovedApiKeyIds
// column that once carried them was dropped with the SQL Server ApiKey entity
// (re-arch C5). Scopes are re-granted per environment in the shared
// ZB.MOM.WW.Auth.ApiKeys store.
return new ApiMethod(overrideName ?? dto.Name, dto.Script)
{
ParameterDefinitions = dto.ParameterDefinitions,
ReturnDefinition = dto.ReturnDefinition,
TimeoutSeconds = dto.TimeoutSeconds,
};
}
// ─────────────────────────────────────────────────────────────────────
// 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.
// C2: the resolution is keyed by the SITE-QUALIFIED name
// (`{SiteIdentifier}/{Name}`), matching the qualified preview-item Name —
// so a same-named connection under a different site can't pick up the
// wrong Skip/Overwrite.
var resolution = ResolveOrDefault(
resolutionMap, "DataConnection", QualifiedConnectionName(dcDto.SiteIdentifier, 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;
}
// ---- C1 Pass 2 — referenced-but-not-carried connections ----
// A binding (or native-alarm-source override) can name a connection that
// exists in the TARGET database but was NOT carried in the bundle's
// DataConnections (e.g. exported without it). Preview correctly does NOT
// block it — it auto-matches against the target. But the main loop above
// only populated the maps for connections the bundle CARRIES, so without
// this pass IdBySourceRef would MISS for such a binding and the instance
// pass would write DataConnectionId = 0 (an invalid FK). Mirror the
// site path's Pass 2: for every distinct (sourceSite, connName) referenced
// by an instance binding / native-alarm override that isn't already mapped,
// resolve the target site (honouring a nameMap redirect to a differently-
// named target connection) and look up the EXISTING target connection by
// name, populating both maps with its real id + name.
foreach (var (sourceSite, connName) in EnumerateReferencedConnectionRefs(content))
{
var key = (sourceSite, connName);
if (result.ContainsKey(key)) continue; // already mapped by the carried-connection loop.
if (!siteBySourceIdentifier.TryGetValue(sourceSite, out var targetSite))
{
// ApplySitesAsync resolved every referenced site; guard so a missing
// entry fails the import rather than writing an orphan FK.
throw new InvalidOperationException(
$"Connection '{sourceSite}/{connName}' references a site that could not be "
+ "resolved to a target.");
}
connMappingByRef.TryGetValue(key, out var mapping);
var targetName = mapping?.Action == MappingAction.MapToExisting
&& mapping.TargetConnectionName is { Length: > 0 } tn
? tn
: connName;
var targetConns = await TargetConnsAsync(targetSite.Id).ConfigureAwait(false);
var existing = targetConns.FirstOrDefault(c =>
string.Equals(c.Name, targetName, StringComparison.Ordinal));
if (existing is null)
{
// Should already be a preview blocker (present in neither bundle nor
// target). Fail with a clear message rather than letting the instance
// pass write DataConnectionId = 0.
throw new InvalidOperationException(
$"Connection '{sourceSite}/{connName}' is referenced by the bundle but is present "
+ "in neither the bundle nor the target — cannot resolve a target connection.");
}
result[key] = existing.Id;
targetNameByRef[key] = existing.Name;
}
return new ResolvedConnectionMaps(result, targetNameByRef);
}
/// <summary>
/// C1: every distinct <c>(sourceSiteIdentifier, connectionName)</c> pair an instance
/// references — via a <see cref="InstanceConnectionBindingDto.ConnectionName"/> or a
/// non-null <see cref="InstanceNativeAlarmSourceOverrideDto.ConnectionNameOverride"/>.
/// Drives the connection-map Pass 2 that resolves references the bundle didn't carry.
/// </summary>
private static IEnumerable<(string Site, string Name)> EnumerateReferencedConnectionRefs(BundleContentDto content)
{
var seen = new HashSet<(string, string)>();
foreach (var inst in content.Instances)
{
foreach (var b in inst.ConnectionBindings)
{
if (!string.IsNullOrEmpty(b.ConnectionName)
&& seen.Add((inst.SiteIdentifier, b.ConnectionName)))
{
yield return (inst.SiteIdentifier, b.ConnectionName);
}
}
foreach (var n in inst.NativeAlarmSourceOverrides)
{
if (!string.IsNullOrEmpty(n.ConnectionNameOverride)
&& seen.Add((inst.SiteIdentifier, n.ConnectionNameOverride)))
{
yield return (inst.SiteIdentifier, n.ConnectionNameOverride);
}
}
}
}
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. After the C1 Pass-2
// in ApplyDataConnectionsAsync, the map carries an entry for every
// referenced connection — carried in the bundle OR auto-matched in the
// target. A binding whose connection name STILL doesn't resolve is a
// structural error (it should already have been a preview blocker +
// a pre-write validation failure); THROW rather than write
// DataConnectionId = 0, which would be an invalid FK on a relational
// provider and a silently-broken binding on the in-memory one.
// A binding may legitimately carry NO connection name (unbound
// attribute) — only a NON-EMPTY name that fails to resolve is an error.
if (!string.IsNullOrEmpty(b.ConnectionName)
&& !connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out _))
{
throw new InvalidOperationException(
$"Instance '{inst.UniqueName}' binding for attribute '{b.AttributeName}' references "
+ $"connection '{sourceSiteIdentifier}/{b.ConnectionName}' which could not be resolved "
+ "to a target connection (present in neither bundle nor target).");
}
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">
/// <item><b>Pass 1 — minimal name-resolution scan.</b> Catches the
/// import-specific crash surface that the full <c>SemanticValidator</c>
/// can't see: identifier-shaped call targets in
/// <c>TemplateScript</c> / <c>ApiMethod</c> bodies that resolve to neither
/// an in-bundle nor a pre-existing target <c>SharedScript</c> /
/// <c>ExternalSystem</c>. Skip-resolved DTOs are excluded from the
/// in-bundle name set so a Skip that would have provided a dependency
/// surfaces here. Fails fast: if Pass 1 finds errors, Pass 2 is not run.</item>
/// <item><b>Pass 2 — full <see cref="SemanticValidator"/>.</b> For each
/// template being imported (Add / Overwrite / Rename — not Skip), build a
/// per-template <see cref="FlattenedConfiguration"/> directly from the DTO
/// (single-template scope — no inheritance / composition resolution, since
/// the inheritance chain is reconstructed only at deploy time) and invoke
/// the same validator the deployment pipeline uses. Errors from every
/// template are aggregated into one list so the operator sees the full
/// surface at once. SharedScripts are passed as <see cref="ResolvedScript"/>
/// values combining bundle + target so call-target checks resolve in either
/// direction.</item>
/// </list>
/// <para>
/// Per-template scoping is intentional: pre-existing target templates that
/// haven't been touched by this bundle aren't run through the validator —
/// otherwise a latent validation issue on an unrelated template (one the
/// operator isn't trying to import) would block the import.
/// </para>
/// </summary>
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
BundleContentDto content,
Dictionary<(string, string), ImportResolution> resolutionMap,
CancellationToken ct)
{
var errors = new List<string>();
// ---- Pass 1: minimal name-resolution scan ----
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
// Skip-resolved names — those aren't being written, so they can't
// satisfy a downstream reference. Renamed entries register under both
// their original DTO name (so the script body in the bundle still
// resolves) AND the new name; the v1 import doesn't rewrite call sites.
var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var s in content.SharedScripts)
{
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
sharedScriptNames.Add(s.Name);
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
{
sharedScriptNames.Add(resolution.RenameTo);
}
}
var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var e in content.ExternalSystems)
{
var resolution = ResolveOrDefault(resolutionMap, "ExternalSystem", e.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
externalSystemNames.Add(e.Name);
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
{
externalSystemNames.Add(resolution.RenameTo);
}
}
// Pre-existing target entries always count as resolvable.
var preExistingSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
foreach (var s in preExistingSharedScripts)
{
sharedScriptNames.Add(s.Name);
}
foreach (var e in await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false))
{
externalSystemNames.Add(e.Name);
}
// Collect every identifier-shaped call target from the bundle's
// templates + api methods. We only check the bundle's bodies here
// (matching PreviewAsync's blocker scan); pre-existing target rows are
// assumed already validated when they were originally written.
var referenced = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
// Skip-resolved templates aren't being written, so their script
// references don't need to resolve.
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced);
foreach (var a in t.Attributes)
{
// Value can hold script-callable design-time expressions;
// DataSourceReference is an OPC UA address-space path (e.g.
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
// delimiter will flag tag-path segments as missing references.
// Symmetric with DetectBlockersAsync.
CollectCallIdentifiers(a.Value, referenced);
}
}
foreach (var m in content.ApiMethods)
{
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
CollectCallIdentifiers(m.Script, referenced);
}
foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
errors.Add(
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
}
// Fail fast — running the full validator over templates that already
// failed name resolution would produce duplicate / lower-quality errors
// (the missing identifier shows up there as "callee not found" too).
if (errors.Count > 0) return errors;
// ---- Pass 2: full SemanticValidator over imported templates ----
// Build the shared-script catalog the validator uses to resolve
// CallShared targets. Combine in-bundle (non-Skip) + pre-existing
// target — same resolution model as Pass 1's name set.
var sharedScripts = new List<ResolvedScript>();
foreach (var s in content.SharedScripts)
{
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var name = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: s.Name;
sharedScripts.Add(new ResolvedScript
{
CanonicalName = name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
});
}
foreach (var s in preExistingSharedScripts)
{
// Pre-existing target wins on duplicate name only when the bundle
// didn't supply it; otherwise the bundle's version (the one about
// to be written) is the right signature surface to validate against.
if (sharedScripts.Any(rs => string.Equals(rs.CanonicalName, s.Name, StringComparison.Ordinal))) continue;
sharedScripts.Add(new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
});
}
foreach (var dto in content.Templates)
{
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
var importedName = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
? resolution.RenameTo
: dto.Name;
var config = BuildFlattenedConfigForValidation(dto, importedName);
var result = _semanticValidator.Validate(config, sharedScripts);
foreach (var entry in result.Errors)
{
errors.Add($"Template '{importedName}': {entry.Message}");
}
}
return errors;
}
/// <summary>
/// Builds a <see cref="FlattenedConfiguration"/> for a single template DTO
/// — the validator's input contract. The bundle DTO carries only the
/// template's own attributes / alarms / scripts (no inheritance / no
/// composition resolution at design time), so the flattening here is a
/// straight 1:1 copy with the alarm on-trigger-script name carried through
/// as the canonical name (the same script's bare name, since composed
/// modules aren't expanded at import time). This is intentionally narrower
/// than the production <c>FlatteningService</c> pipeline, which needs a
/// concrete <c>Instance</c> plus site / connection context that doesn't
/// exist yet at design time. The deployment-time flatten will revalidate
/// against the full graph; this pass catches the same-template-scope
/// errors that operators would otherwise only hit at deploy time.
/// </summary>
private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName)
{
var attributes = new List<ResolvedAttribute>(dto.Attributes.Count);
foreach (var a in dto.Attributes)
{
attributes.Add(new ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
ElementDataType = a.ElementDataType?.ToString(),
IsLocked = a.IsLocked,
Description = a.Description,
DataSourceReference = a.DataSourceReference,
Source = "Template",
});
}
var alarms = new List<ResolvedAlarm>(dto.Alarms.Count);
foreach (var al in dto.Alarms)
{
alarms.Add(new ResolvedAlarm
{
CanonicalName = al.Name,
Description = al.Description,
PriorityLevel = al.PriorityLevel,
IsLocked = al.IsLocked,
TriggerType = al.TriggerType.ToString(),
TriggerConfiguration = al.TriggerConfiguration,
// The bundle carries the on-trigger script by NAME (not id);
// at this single-template-scope validation step the bare name
// IS the canonical name, so just pass it through.
OnTriggerScriptCanonicalName = string.IsNullOrEmpty(al.OnTriggerScriptName) ? null : al.OnTriggerScriptName,
Source = "Template",
});
}
var scripts = new List<ResolvedScript>(dto.Scripts.Count);
foreach (var s in dto.Scripts)
{
scripts.Add(new ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition,
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
Source = "Template",
});
}
return new FlattenedConfiguration
{
InstanceUniqueName = templateName,
TemplateId = 0,
SiteId = 0,
AreaId = null,
Attributes = attributes,
Alarms = alarms,
Scripts = scripts,
Connections = null,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
}