c4dcd9bc02
BundleImporter.ApplyAsync opened a user-initiated transaction directly,
but the central ConfigurationDb context is configured with
EnableRetryOnFailure. SqlServerRetryingExecutionStrategy rejects
user-initiated transactions, so every bundle import threw on any real
SQL Server ('...does not support user-initiated transactions.'). The
whole Transport suite ran on the in-memory provider (no retrying
strategy, BeginTransaction is a no-op) so it never surfaced.
Extract the transactional apply into ApplyMergeAsync and drive it via
_dbContext.Database.CreateExecutionStrategy().ExecuteAsync, so the
strategy owns the BeginTransaction -> apply -> Commit unit as one
retriable block. The delegate resets per-attempt state (change tracker
+ ApplyMergeAsync rebuilds its own summary/resolution accumulators) so a
retried attempt cannot double-apply; a rollback failure is captured and
surfaced on the BundleImportFailed audit row exactly as before. Post-
commit side effects (ScriptArtifactsChanged publish, session zero/
remove) moved outside the retriable delegate so they run once.
Regression test: BundleImporterRetryingStrategyTests runs the import on
SQLite with a retrying execution strategy (RetriesOnFailure == true,
arming the same guard as production). It fails with the exact production
error on the pre-fix code and passes after. Existing rollback/apply
contracts unchanged (104 integration + 154 unit tests green).
4800 lines
236 KiB
C#
4800 lines
236 KiB
C#
using System.IO.Compression;
|
||
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using Microsoft.CodeAnalysis;
|
||
using Microsoft.CodeAnalysis.CSharp;
|
||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||
using Microsoft.EntityFrameworkCore;
|
||
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.Scripting;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Scripting;
|
||
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.ScriptAnalysis;
|
||
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
|
||
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). Legacy 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;
|
||
// Optional. Implemented in DeploymentManager (where the flattening
|
||
// pipeline lives) and surfaced via the Commons IStaleInstanceProbe seam. Null
|
||
// in a host that registers Transport without DeploymentManager — staleness is
|
||
// then best-effort empty (informational only, never gates the import).
|
||
private readonly IStaleInstanceProbe? _staleInstanceProbe;
|
||
private readonly IScriptArtifactChangeBus? _scriptArtifactChangeBus;
|
||
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.</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>
|
||
/// <param name="staleInstanceProbe">Optional: recomputes a deployed instance's current revision hash so the importer can enumerate instances stale-ed by a template overwrite. Null when no flattening pipeline is registered (Transport-without-DeploymentManager hosts) — staleness is then skipped.</param>
|
||
/// <param name="scriptArtifactChangeBus">Optional: node-local bus notified AFTER a successful commit that script-bearing artifacts (ApiMethod / SharedScript / Template) were overwritten or renamed, so compiled-handler caches can invalidate. Null on hosts that don't register it (staleness/invalidation then relies on the consumer's own self-heal).</param>
|
||
/// <param name="logger">Optional logger.</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,
|
||
IStaleInstanceProbe? staleInstanceProbe = null,
|
||
IScriptArtifactChangeBus? scriptArtifactChangeBus = null,
|
||
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));
|
||
_staleInstanceProbe = staleInstanceProbe;
|
||
_scriptArtifactChangeBus = scriptArtifactChangeBus;
|
||
_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);
|
||
}
|
||
|
||
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 ----
|
||
// 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);
|
||
var allTemplateStubs = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
var templateNameById = allTemplateStubs.ToDictionary(t => t.Id, t => t.Name);
|
||
foreach (var tDto in content.Templates)
|
||
{
|
||
hydratedByName.TryGetValue(tDto.Name, out var existing);
|
||
items.Add(_diff.CompareTemplate(tDto, existing, folderNameById, templateNameById));
|
||
}
|
||
|
||
// ---- 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));
|
||
}
|
||
|
||
// ---- SmsConfigurations (no by-AccountSid lookup — scan GetAll) ----
|
||
var allSms = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
|
||
var smsBySid = allSms.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
|
||
foreach (var sms in content.SmsConfigs)
|
||
{
|
||
smsBySid.TryGetValue(sms.AccountSid, out var existing);
|
||
items.Add(_diff.CompareSmsConfiguration(sms, existing));
|
||
}
|
||
|
||
// ---- ApiKeys ----
|
||
// Inbound API keys are not transported between environments.
|
||
// New bundles never carry a keys section. A legacy 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));
|
||
}
|
||
|
||
// ---- 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) ----
|
||
// 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 ----
|
||
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.
|
||
/// <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>();
|
||
|
||
// ---- 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 targetTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
var targetTemplateNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var t in targetTemplates)
|
||
{
|
||
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;
|
||
// 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 referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
||
var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||
var locallyDeclaredInApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var t in content.Templates)
|
||
{
|
||
foreach (var s in t.Scripts)
|
||
{
|
||
CollectCallIdentifiers(s.Code, referencedFromTemplates);
|
||
CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
|
||
}
|
||
foreach (var a in t.Attributes)
|
||
{
|
||
// Attribute.Value is a typed-literal default (int/double/string/list,
|
||
// decoded by AttributeValueCodec — never compiled), so it is not a
|
||
// trust surface; we still scan it for identifier-shaped tokens, but
|
||
// any finding is only ever an advisory template warning.
|
||
// DataSourceReference is an OPC UA node address path (e.g.
|
||
// "ns=3;s=Tank.Level") owned by the device 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, referencedFromTemplates);
|
||
CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
||
CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
|
||
}
|
||
|
||
// For each candidate, only report it 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. A
|
||
// candidate genuinely unresolved in an ApiMethod (referenced there and NOT
|
||
// locally declared there) is a hard Blocker; a candidate genuinely
|
||
// unresolved only in a template is a non-blocking Warning.
|
||
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
||
allCandidates.UnionWith(referencedFromApiMethods);
|
||
foreach (var candidate in allCandidates.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;
|
||
|
||
var apiUnresolved = referencedFromApiMethods.Contains(candidate)
|
||
&& !locallyDeclaredInApiMethods.Contains(candidate);
|
||
var templateUnresolved = referencedFromTemplates.Contains(candidate)
|
||
&& !locallyDeclaredInTemplates.Contains(candidate);
|
||
if (!apiUnresolved && !templateUnresolved) continue; // satisfied by a local decl in its own origin
|
||
|
||
var fromApiMethod = apiUnresolved;
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: "Reference",
|
||
Name: candidate,
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: fromApiMethod ? ConflictKind.Blocker : ConflictKind.Warning,
|
||
FieldDiffJson: null,
|
||
BlockerReason: fromApiMethod
|
||
? $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."
|
||
: $"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; the deploy-time gate re-validates."));
|
||
}
|
||
|
||
foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap: null))
|
||
{
|
||
IReadOnlyList<string> violations;
|
||
try
|
||
{
|
||
violations = ScriptTrustValidator.FindViolations(code);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Fail closed (mirrors the apply-time gate): a validator throw on
|
||
// one body surfaces as a Blocker for that script, not a preview crash.
|
||
_logger?.LogWarning(ex,
|
||
"Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}' during preview; surfacing as a Blocker.",
|
||
kind, entityName, scriptLabel);
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: kind,
|
||
Name: $"{entityName}.{scriptLabel}",
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: ConflictKind.Blocker,
|
||
FieldDiffJson: null,
|
||
BlockerReason: "Script trust analysis failed to complete — rejected."));
|
||
continue;
|
||
}
|
||
foreach (var violation in violations)
|
||
{
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: kind,
|
||
Name: $"{entityName}.{scriptLabel}",
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: ConflictKind.Blocker,
|
||
FieldDiffJson: null,
|
||
BlockerReason: $"Script trust violation — {violation}"));
|
||
}
|
||
}
|
||
|
||
// N4: advisory warning rows for instance overrides that target a LOCKED
|
||
// template member — the flattener drops them, so surface the inert row in the
|
||
// wizard (Kind: Warning, non-blocking). resolutionMap is null at preview time,
|
||
// so every instance is scanned.
|
||
foreach (var (instance, message) in CollectLockedOverrideWarnings(content, resolutionMap: null, targetTemplates))
|
||
{
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: "Instance",
|
||
Name: instance,
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: ConflictKind.Warning,
|
||
FieldDiffJson: null,
|
||
BlockerReason: message));
|
||
}
|
||
|
||
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>
|
||
/// Collects the names of every local function / method DECLARED inside a
|
||
/// script body, so the reference heuristic doesn't mistake a script's own
|
||
/// helper (<c>decimal ComputeRate(...) => ...; return ComputeRate(x);</c>)
|
||
/// for a missing SharedScript / ExternalSystem. Uses a Roslyn script-kind
|
||
/// parse (partial trees are fine — a body with syntax errors still yields
|
||
/// the declarations Roslyn could recover). Purely additive noise-suppression:
|
||
/// on any parse failure the sink is simply left unchanged.
|
||
/// </summary>
|
||
private static void CollectLocalDeclarations(string? body, HashSet<string> sink)
|
||
{
|
||
if (string.IsNullOrEmpty(body)) return;
|
||
try
|
||
{
|
||
var root = CSharpSyntaxTree
|
||
.ParseText(body, new CSharpParseOptions(kind: SourceCodeKind.Script))
|
||
.GetRoot();
|
||
foreach (var fn in root.DescendantNodes().OfType<LocalFunctionStatementSyntax>())
|
||
{
|
||
sink.Add(fn.Identifier.ValueText);
|
||
}
|
||
foreach (var m in root.DescendantNodes().OfType<MethodDeclarationSyntax>())
|
||
{
|
||
sink.Add(m.Identifier.ValueText);
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Best-effort: a parse failure just means no declarations are
|
||
// excluded for this body — the finding (at worst) surfaces as an
|
||
// advisory warning, never a hard error for template scripts.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Enumerates every executable C# surface a bundle carries that the
|
||
/// trust gate must vet: non-Skip template scripts + their Expression-trigger
|
||
/// bodies, template alarm Expression-trigger bodies, shared scripts,
|
||
/// ApiMethod scripts, AND instance alarm-override trigger expressions
|
||
/// (<c>InstanceAlarmOverrideDto.TriggerConfigurationOverride</c>). Expression
|
||
/// triggers — template alarms AND the instance overrides that replace them in
|
||
/// the flattened config — compile and execute at the site
|
||
/// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
|
||
/// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
|
||
/// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
|
||
/// scriptLabel, code)</c> so callers can format either an error string or a
|
||
/// preview row.
|
||
/// </summary>
|
||
private static IEnumerable<(string Kind, string EntityName, string ScriptLabel, string Code)> EnumerateTrustGatedScripts(
|
||
BundleContentDto content,
|
||
Dictionary<(string, string), ImportResolution>? resolutionMap)
|
||
{
|
||
foreach (var t in content.Templates)
|
||
{
|
||
if (IsSkipResolution(resolutionMap, "Template", t.Name)) continue;
|
||
foreach (var s in t.Scripts)
|
||
{
|
||
if (!string.IsNullOrEmpty(s.Code))
|
||
{
|
||
yield return ("Template", t.Name, s.Name, s.Code);
|
||
}
|
||
var scriptExpr = ExtractTriggerExpression(s.TriggerType, s.TriggerConfiguration);
|
||
if (!string.IsNullOrEmpty(scriptExpr))
|
||
{
|
||
yield return ("Template", t.Name, $"{s.Name} (trigger expression)", scriptExpr);
|
||
}
|
||
}
|
||
foreach (var a in t.Alarms)
|
||
{
|
||
var alarmExpr = ExtractTriggerExpression(a.TriggerType.ToString(), a.TriggerConfiguration);
|
||
if (!string.IsNullOrEmpty(alarmExpr))
|
||
{
|
||
yield return ("Template", t.Name, $"{a.Name} (alarm trigger expression)", alarmExpr);
|
||
}
|
||
}
|
||
}
|
||
foreach (var s in content.SharedScripts)
|
||
{
|
||
if (IsSkipResolution(resolutionMap, "SharedScript", s.Name)) continue;
|
||
if (!string.IsNullOrEmpty(s.Code))
|
||
{
|
||
yield return ("SharedScript", s.Name, s.Name, s.Code);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
if (IsSkipResolution(resolutionMap, "ApiMethod", m.Name)) continue;
|
||
if (!string.IsNullOrEmpty(m.Script))
|
||
{
|
||
yield return ("ApiMethod", m.Name, m.Name, m.Script);
|
||
}
|
||
}
|
||
foreach (var i in content.Instances)
|
||
{
|
||
if (IsSkipResolution(resolutionMap, "Instance", i.UniqueName)) continue;
|
||
foreach (var o in i.AlarmOverrides)
|
||
{
|
||
// The DTO carries no trigger type (the type lives on the template
|
||
// alarm), so gate ANY override config carrying an {"expression":...}
|
||
// string body: if the overridden alarm is not Expression-triggered
|
||
// the body never executes, but vetting it anyway is fail-safe — the
|
||
// structured (HiLo/threshold) configs have no "expression" key, so
|
||
// there is no false-positive channel.
|
||
var expr = ExtractExpressionBody(o.TriggerConfigurationOverride);
|
||
if (!string.IsNullOrEmpty(expr))
|
||
{
|
||
yield return ("Instance", i.UniqueName,
|
||
$"{o.AlarmCanonicalName} (alarm trigger override expression)", expr);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
|
||
/// configuration when the trigger type is <c>Expression</c> — the same shape
|
||
/// the site's <c>TriggerExpressionGlobals</c> compiles. Returns null for any
|
||
/// non-Expression trigger, empty config, or malformed JSON (nothing to gate).
|
||
/// </summary>
|
||
private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
|
||
{
|
||
if (triggerType is null
|
||
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return null;
|
||
}
|
||
return ExtractExpressionBody(triggerConfigJson);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
|
||
/// configuration, WITHOUT a trigger-type guard — used where the caller has no
|
||
/// trigger type to check (an instance alarm override; the type lives on the
|
||
/// template alarm). A config carrying no <c>"expression"</c> string key (the
|
||
/// structured HiLo/threshold shapes) or malformed JSON yields null — nothing to
|
||
/// gate.
|
||
/// </summary>
|
||
private static string? ExtractExpressionBody(string? triggerConfigJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(triggerConfigJson))
|
||
{
|
||
return null;
|
||
}
|
||
try
|
||
{
|
||
using var doc = JsonDocument.Parse(triggerConfigJson);
|
||
if (doc.RootElement.ValueKind == JsonValueKind.Object
|
||
&& doc.RootElement.TryGetProperty("expression", out var expr)
|
||
&& expr.ValueKind == JsonValueKind.String)
|
||
{
|
||
return expr.GetString();
|
||
}
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// Malformed trigger config isn't executable as an expression — nothing
|
||
// to gate here (a separate validation surfaces the malformed config).
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static bool IsSkipResolution(
|
||
Dictionary<(string, string), ImportResolution>? resolutionMap, string entityType, string name)
|
||
=> resolutionMap != null
|
||
&& ResolveOrDefault(resolutionMap, entityType, name).Action == ResolutionAction.Skip;
|
||
|
||
/// <summary>
|
||
/// Best-effort scan for instance overrides that target a LOCKED template member.
|
||
/// The flattener drops such overrides (an override on a locked attribute / alarm /
|
||
/// native-alarm-source never takes effect), so the bundle carries an inert row —
|
||
/// this surfaces an advisory warning so the operator learns the config won't apply,
|
||
/// WITHOUT blocking the import or changing what gets written (bundle fidelity keeps
|
||
/// the row; the flattener stays the behavioural authority).
|
||
/// <para>
|
||
/// Matches locked members by DIRECT name only — own + inherited placeholder rows
|
||
/// both carry the <c>IsLocked</c> flag, so a lock on either matches. Overrides
|
||
/// addressing composed path-qualified members (<c>y1.z.Val</c>) or derived-shadow
|
||
/// locks (<c>LockedInDerived</c> on a base the placeholder row doesn't reflect) may
|
||
/// not match a direct row — an unmatched name emits NO warning (never a false
|
||
/// positive; the ManagementActor and flattener remain the enforcement points). The
|
||
/// instance's template is resolved from the bundle DTO (the version about to be
|
||
/// written) first, else the pre-existing target template.
|
||
/// </para>
|
||
/// </summary>
|
||
private static IReadOnlyList<(string Instance, string Message)> CollectLockedOverrideWarnings(
|
||
BundleContentDto content,
|
||
Dictionary<(string, string), ImportResolution>? resolutionMap,
|
||
IReadOnlyList<Template> targetTemplates)
|
||
{
|
||
var warnings = new List<(string, string)>();
|
||
|
||
var bundleTemplates = new Dictionary<string, TemplateDto>(StringComparer.Ordinal);
|
||
foreach (var t in content.Templates) bundleTemplates[t.Name] = t;
|
||
var targetByName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||
foreach (var t in targetTemplates) targetByName[t.Name] = t;
|
||
|
||
foreach (var inst in content.Instances)
|
||
{
|
||
if (IsSkipResolution(resolutionMap, "Instance", inst.UniqueName)) continue;
|
||
if (string.IsNullOrEmpty(inst.TemplateName)) continue;
|
||
|
||
HashSet<string> lockedAttrs;
|
||
HashSet<string> lockedAlarms;
|
||
HashSet<string> lockedSources;
|
||
if (bundleTemplates.TryGetValue(inst.TemplateName, out var dto))
|
||
{
|
||
lockedAttrs = new(dto.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||
lockedAlarms = new(dto.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||
lockedSources = new(dto.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||
}
|
||
else if (targetByName.TryGetValue(inst.TemplateName, out var tgt))
|
||
{
|
||
lockedAttrs = new(tgt.Attributes.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||
lockedAlarms = new(tgt.Alarms.Where(a => a.IsLocked).Select(a => a.Name), StringComparer.Ordinal);
|
||
lockedSources = new(tgt.NativeAlarmSources.Where(s => s.IsLocked).Select(s => s.Name), StringComparer.Ordinal);
|
||
}
|
||
else
|
||
{
|
||
// Template unresolvable — a separate blocker/validation covers that.
|
||
continue;
|
||
}
|
||
|
||
foreach (var o in inst.AttributeOverrides)
|
||
{
|
||
if (lockedAttrs.Contains(o.AttributeName))
|
||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "attribute", o.AttributeName)));
|
||
}
|
||
foreach (var o in inst.AlarmOverrides)
|
||
{
|
||
if (lockedAlarms.Contains(o.AlarmCanonicalName))
|
||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "alarm", o.AlarmCanonicalName)));
|
||
}
|
||
foreach (var o in inst.NativeAlarmSourceOverrides)
|
||
{
|
||
if (lockedSources.Contains(o.SourceCanonicalName))
|
||
warnings.Add((inst.UniqueName, LockedOverrideMessage(inst.UniqueName, "native-alarm-source", o.SourceCanonicalName)));
|
||
}
|
||
}
|
||
return warnings;
|
||
}
|
||
|
||
private static string LockedOverrideMessage(string unique, string kind, string name) =>
|
||
$"Instance '{unique}' {kind} override '{name}' targets a LOCKED template member — " +
|
||
"the flattener ignores it, so this part of the bundle's instance config will never take effect.";
|
||
|
||
/// <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", "HAVING", "VALUES", "DELETE", "DISTINCT", "LIMIT",
|
||
|
||
// Extended stdlib / BCL surface commonly reached from scripts.
|
||
"Regex", "Match", "Matches", "IsMatch", "Replace", "Split",
|
||
"StringBuilder", "Append", "AppendLine", "Parse", "TryParse",
|
||
"Format", "Join", "Abs", "Round", "Min", "Max", "Floor", "Ceiling",
|
||
"Pow", "Sqrt", "Json", "Serialize", "Deserialize", "Where", "Select",
|
||
"First", "FirstOrDefault", "Any", "All", "Count", "Sum", "Average",
|
||
"OrderBy", "OrderByDescending", "GroupBy", "Distinct", "Add", "Remove",
|
||
"Clear", "Contains", "ContainsKey", "TryGetValue", "StartsWith",
|
||
"EndsWith", "Substring", "Trim", "ToUpper", "ToLower",
|
||
};
|
||
|
||
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>
|
||
/// 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();
|
||
// 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;
|
||
|
||
// The central ConfigurationDb context is configured with
|
||
// EnableRetryOnFailure (ServiceCollectionExtensions), and
|
||
// SqlServerRetryingExecutionStrategy REJECTS a user-initiated
|
||
// transaction opened outside the strategy ("The configured execution
|
||
// strategy 'SqlServerRetryingExecutionStrategy' does not support
|
||
// user-initiated transactions."), so opening BeginTransactionAsync
|
||
// directly here throws on any real SQL Server. Run the whole
|
||
// BeginTransaction -> apply -> Commit unit INSIDE the execution strategy
|
||
// so the strategy owns it and re-executes it as one retriable unit on a
|
||
// transient fault. On the in-memory provider CreateExecutionStrategy()
|
||
// returns a non-retrying strategy, so this stays a single straight-
|
||
// through call there (behaviour unchanged).
|
||
//
|
||
// Retry idempotency: the strategy re-runs the WHOLE delegate on a
|
||
// transient fault, so the change tracker is cleared at the top of the
|
||
// delegate (adds staged by the failed attempt, whose transaction has
|
||
// already rolled back, must not leak into the retry) and ApplyMergeAsync
|
||
// rebuilds its own summary/resolution accumulators per call — otherwise
|
||
// a retry would double-apply. A rollback failure captured inside the
|
||
// delegate is surfaced to the outer catch via rollbackFailureMessage so
|
||
// the BundleImportFailed row still records both faults.
|
||
var strategy = _dbContext.Database.CreateExecutionStrategy();
|
||
string? rollbackFailureMessage = null;
|
||
ImportResult result;
|
||
try
|
||
{
|
||
result = await strategy.ExecuteAsync(async () =>
|
||
{
|
||
// Reset per-attempt state so a retried delegate re-runs clean.
|
||
_dbContext.ChangeTracker.Clear();
|
||
rollbackFailureMessage = null;
|
||
|
||
// 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, ApplyMergeAsync defers the
|
||
// SINGLE final SaveChangesAsync until the very end — every
|
||
// Add*Async + LogAsync call only stages on the change tracker,
|
||
// so throwing before that naturally undoes the whole apply on
|
||
// both providers.
|
||
var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// ApplyMergeAsync stages the whole merge and issues the
|
||
// single deferred final SaveChangesAsync itself; here we only
|
||
// commit the transaction it ran under.
|
||
var applied = await ApplyMergeAsync(
|
||
content, resolutions, nameMap, session, bundleImportId, user, ct).ConfigureAwait(false);
|
||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||
await tx.DisposeAsync().ConfigureAwait(false);
|
||
return applied;
|
||
}
|
||
catch
|
||
{
|
||
// Roll back explicitly (rather than leaning on Dispose) so a
|
||
// rollback that itself throws does not mask the ORIGINAL
|
||
// exception — capture it and let the original propagate to
|
||
// the strategy, which decides whether to retry.
|
||
try
|
||
{
|
||
await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch (Exception rbEx)
|
||
{
|
||
rollbackFailureMessage = rbEx.Message;
|
||
}
|
||
try { await tx.DisposeAsync().ConfigureAwait(false); }
|
||
catch { /* dispose-after-throw must not mask the original cause */ }
|
||
|
||
// Clear the change tracker — on the in-memory provider the
|
||
// rollback is a no-op and the staged adds would otherwise
|
||
// persist on the next SaveChangesAsync (a retry or the
|
||
// failure-row write below).
|
||
_dbContext.ChangeTracker.Clear();
|
||
throw;
|
||
}
|
||
}).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// The strategy exhausted its retries (or threw a non-transient
|
||
// error). The failed attempt already rolled back and cleared the
|
||
// change tracker inside the delegate, so all that remains here is
|
||
// the top-level BundleImportFailed audit row.
|
||
//
|
||
// 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 = rollbackFailureMessage,
|
||
},
|
||
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;
|
||
}
|
||
|
||
// Post-commit side effects — run ONCE, only after a durable commit, so a
|
||
// retried attempt neither publishes twice nor releases the session early.
|
||
PublishScriptArtifactChanges(resolutions);
|
||
|
||
// 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 result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies one bundle's merge inside the caller's transaction: semantic
|
||
/// validation, every Apply* pass, the name-keyed FK rewires, the stale-
|
||
/// instance computation and the BundleImported audit row, ending with the
|
||
/// deferred final SaveChangesAsync staged by the caller. Does NOT begin or
|
||
/// commit the transaction — <see cref="ApplyAsync"/> owns it so the whole
|
||
/// unit can run inside the context's execution strategy (EnableRetryOnFailure
|
||
/// rejects user-initiated transactions opened outside the strategy). Safe to
|
||
/// re-run: the caller clears the change tracker before each attempt, and
|
||
/// every accumulator here (summary counts, resolution map) is rebuilt per
|
||
/// call.
|
||
/// </summary>
|
||
private async Task<ImportResult> ApplyMergeAsync(
|
||
BundleContentDto content,
|
||
IReadOnlyList<ImportResolution> resolutions,
|
||
BundleNameMap nameMap,
|
||
BundleSession session,
|
||
Guid bundleImportId,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
var resolutionMap = resolutions.ToDictionary(
|
||
r => (r.EntityType, r.Name),
|
||
r => r);
|
||
var summary = new ImportSummary();
|
||
|
||
// Inbound API keys are not transported between environments.
|
||
// A legacy 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;
|
||
|
||
// 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 (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, validationWarnings) =
|
||
await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||
// 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);
|
||
}
|
||
|
||
// Advisory template-script reference findings never block the import;
|
||
// log them and surface them on the ImportResult so the operator sees
|
||
// the same advisory the preview showed. The deploy-time gate is the
|
||
// authoritative re-validation.
|
||
if (validationWarnings.Count > 0)
|
||
{
|
||
_logger?.LogWarning(
|
||
"Bundle import {BundleImportId}: {Count} advisory import warning(s): {Warnings}",
|
||
bundleImportId, validationWarnings.Count, string.Join("; ", validationWarnings));
|
||
}
|
||
|
||
// ---- 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);
|
||
await ApplySmsConfigsAsync(content.SmsConfigs, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||
// Inbound API keys are NOT applied from a bundle — any keys
|
||
// in a legacy bundle were counted above (apiKeysIgnored) and are skipped.
|
||
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||
|
||
// 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);
|
||
await ResolveInheritanceEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||
// Folder parent edges are name-keyed too — resolve them AFTER the flush
|
||
// above so every imported folder has a materialised id to point at
|
||
// (the Add pass created the folders parent-less). Mirrors the template
|
||
// inheritance/composition rewires.
|
||
await ResolveFolderParentEdgesAsync(content.TemplateFolders, resolutionMap, user, ct).ConfigureAwait(false);
|
||
// External-system methods have no navigation on the parent definition,
|
||
// so an Add can't cascade them — persist them here, once the parent
|
||
// system has a real id from the flush above. (Overwrite already syncs
|
||
// its methods inline against the existing parent id.)
|
||
await PersistAddedExternalSystemMethodsAsync(content.ExternalSystems, resolutionMap, user, ct).ConfigureAwait(false);
|
||
|
||
// ---- Import-time acyclicity guard over the merged template graph ----
|
||
// The two rewire passes above have STAGED (not yet saved) the bundle's
|
||
// inheritance (ParentTemplateId) and composition edges onto the tracked
|
||
// target templates. A bundle can still close a loop through a pre-existing
|
||
// target template — e.g. Overwrite a base so it now inherits from (or
|
||
// composes) one of its own descendants. The design-time save-gate enforces
|
||
// acyclicity on individual edits, but an import merges many edges at once,
|
||
// so re-check the whole merged graph here. On a cycle we throw and the
|
||
// catch below rolls the whole import back (ChangeTracker.Clear on the
|
||
// in-memory provider) BEFORE any of it is persisted; deliberately no
|
||
// SaveChanges precedes this check so nothing survives that rollback.
|
||
EnsureNoTemplateGraphCycles();
|
||
|
||
// ---- 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);
|
||
|
||
// ---- Pre-commit point: stale-instance computation ----
|
||
// Everything the import will write is staged on the change tracker at
|
||
// this point but NOT yet committed. This 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.
|
||
var staleInstanceIds = await ComputeStaleInstanceIdsAsync(
|
||
content, resolutionMap, ct).ConfigureAwait(false);
|
||
|
||
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,
|
||
},
|
||
// 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);
|
||
|
||
return new ImportResult(
|
||
BundleImportId: bundleImportId,
|
||
Added: summary.Added,
|
||
Overwritten: summary.Overwritten,
|
||
Skipped: summary.Skipped,
|
||
Renamed: summary.Renamed,
|
||
StaleInstanceIds: staleInstanceIds,
|
||
AuditEventCorrelation: bundleImportId.ToString(),
|
||
ApiKeysIgnored: apiKeysIgnored,
|
||
Warnings: validationWarnings);
|
||
}
|
||
|
||
/// <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>
|
||
/// Enumerates the deployed instances stale-ed by this import.
|
||
/// Overwriting a template changes the flattened-config hash of every instance
|
||
/// of that template, so a deployed instance's freshly-computed revision hash
|
||
/// will no longer match the hash captured in its <c>DeployedConfigSnapshot</c>
|
||
/// at deploy time — it surfaces as "stale" on the Deployments page. This walks
|
||
/// the templates THIS import OVERWROTE, finds each one's deployed instances
|
||
/// (State <see cref="InstanceState.Enabled"/>/<see cref="InstanceState.Disabled"/>
|
||
/// with a snapshot), recomputes their current hash through
|
||
/// <see cref="IStaleInstanceProbe"/> (which reads the just-staged template
|
||
/// rows on the shared change tracker), and collects the ids whose hash drifted.
|
||
/// <para>
|
||
/// Freshly-IMPORTED instances are excluded structurally: they enter the target
|
||
/// <see cref="InstanceState.NotDeployed"/> (no snapshot), so they're "new", not
|
||
/// "stale". Best-effort: a probe that returns null or throws for one instance
|
||
/// is skipped (logged), never aborting the import — staleness is informational
|
||
/// and the import's own success path is authoritative. Returns an empty list
|
||
/// when no probe is wired (Transport-without-DeploymentManager hosts).
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task<IReadOnlyList<int>> ComputeStaleInstanceIdsAsync(
|
||
BundleContentDto content,
|
||
Dictionary<(string EntityType, string Name), ImportResolution> resolutionMap,
|
||
CancellationToken ct)
|
||
{
|
||
// No flattening pipeline registered → can't recompute hashes. Staleness is
|
||
// informational, so degrade gracefully to "none".
|
||
if (_staleInstanceProbe is null || content.Templates.Count == 0)
|
||
{
|
||
return Array.Empty<int>();
|
||
}
|
||
|
||
// Which templates did THIS import overwrite? A bundle template is
|
||
// overwritten when its resolution Action == Overwrite AND a target template
|
||
// of that name already exists (otherwise the Overwrite branch falls through
|
||
// to Add — see ApplyTemplatesAsync). Map those names to their target ids.
|
||
var targetTemplateIdByName = (await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false))
|
||
.GroupBy(t => t.Name, StringComparer.Ordinal)
|
||
.ToDictionary(g => g.Key, g => g.First().Id, StringComparer.Ordinal);
|
||
|
||
var overwrittenTemplateIds = new HashSet<int>();
|
||
foreach (var dto in content.Templates)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||
if (resolution.Action != ResolutionAction.Overwrite) continue;
|
||
if (targetTemplateIdByName.TryGetValue(dto.Name, out var templateId))
|
||
{
|
||
overwrittenTemplateIds.Add(templateId);
|
||
}
|
||
}
|
||
|
||
if (overwrittenTemplateIds.Count == 0)
|
||
{
|
||
return Array.Empty<int>();
|
||
}
|
||
|
||
var staleIds = new List<int>();
|
||
foreach (var templateId in overwrittenTemplateIds)
|
||
{
|
||
var instances = await _templateRepo
|
||
.GetInstancesByTemplateIdAsync(templateId, ct).ConfigureAwait(false);
|
||
foreach (var instance in instances)
|
||
{
|
||
// NotDeployed instances (including everything this import just
|
||
// created/overwrote) are "new", not "stale" — skip them.
|
||
if (instance.State is not (InstanceState.Enabled or InstanceState.Disabled))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
try
|
||
{
|
||
// A deployed instance has a snapshot; its absence means the
|
||
// instance is not actually deployed (defensive — State already
|
||
// gated this) so there's no baseline to compare against.
|
||
var snapshot = await _dbContext.DeployedConfigSnapshots
|
||
.AsNoTracking()
|
||
.Where(s => s.InstanceId == instance.Id)
|
||
.OrderByDescending(s => s.DeployedAt)
|
||
.FirstOrDefaultAsync(ct)
|
||
.ConfigureAwait(false);
|
||
if (snapshot is null) continue;
|
||
|
||
var currentHash = await _staleInstanceProbe
|
||
.GetCurrentRevisionHashAsync(instance.Id, ct).ConfigureAwait(false);
|
||
// null => hash indeterminate; don't claim staleness either way.
|
||
if (currentHash is null) continue;
|
||
|
||
if (!string.Equals(currentHash, snapshot.RevisionHash, StringComparison.Ordinal))
|
||
{
|
||
staleIds.Add(instance.Id);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Best-effort: a single instance's hash failure must not abort
|
||
// the (already-successful) import. Skip it and move on.
|
||
_logger?.LogWarning(ex,
|
||
"Stale-instance hash computation failed for instance {InstanceId} (template {TemplateId}); skipping.",
|
||
instance.Id, templateId);
|
||
}
|
||
}
|
||
}
|
||
|
||
return staleIds;
|
||
}
|
||
|
||
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
|
||
private sealed class ImportSummary
|
||
{
|
||
/// <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);
|
||
await SyncTemplateNativeAlarmSourcesAsync(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>
|
||
/// <summary>
|
||
/// Publishes one <see cref="ScriptArtifactsChanged"/> per script-bearing
|
||
/// artifact kind (ApiMethod / SharedScript / Template) for every non-Skip
|
||
/// resolution — Overwrite, Rename, AND Add — using the post-resolution (renamed)
|
||
/// names. An Add is NOT "nothing cached yet": an artifact deleted on the target and
|
||
/// re-imported as Add under the same name can still have a stale
|
||
/// compiled-handler/<c>_knownBadMethods</c> entry on any node, so it must notify too.
|
||
/// Over-approximation stays safe (the bus is advisory, at-least-once; consumers
|
||
/// self-heal), which matches its stated contract. Only Skip resolutions are excluded
|
||
/// (nothing was written). Fully guarded: never throws (it runs after the transaction
|
||
/// has committed).
|
||
/// </summary>
|
||
private void PublishScriptArtifactChanges(IReadOnlyList<ImportResolution> resolutions)
|
||
{
|
||
if (_scriptArtifactChangeBus is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
PublishKind(resolutions, "ApiMethod", ScriptArtifactKinds.ApiMethod);
|
||
PublishKind(resolutions, "SharedScript", ScriptArtifactKinds.SharedScript);
|
||
PublishKind(resolutions, "Template", ScriptArtifactKinds.Template);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// The import already committed — a publish fault must never surface as
|
||
// an import failure. Swallow-and-log (the bus itself also guards
|
||
// per-subscriber, so this only catches pathological cases).
|
||
_logger?.LogError(ex, "Publishing script-artifact-change notifications after a committed bundle import failed; ignored.");
|
||
}
|
||
|
||
void PublishKind(IReadOnlyList<ImportResolution> all, string entityType, string kind)
|
||
{
|
||
var names = all
|
||
.Where(r => string.Equals(r.EntityType, entityType, StringComparison.Ordinal)
|
||
&& r.Action is not ResolutionAction.Skip)
|
||
.Select(r => r.Action == ResolutionAction.Rename ? r.RenameTo ?? r.Name : r.Name)
|
||
.Distinct(StringComparer.Ordinal)
|
||
.ToList();
|
||
if (names.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_scriptArtifactChangeBus!.Publish(new ScriptArtifactsChanged(
|
||
ArtifactKind: kind,
|
||
Names: names,
|
||
Source: "BundleImport",
|
||
OccurredAtUtc: _timeProvider.GetUtcNow()));
|
||
}
|
||
}
|
||
|
||
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,
|
||
LockedInDerived = a.LockedInDerived,
|
||
});
|
||
}
|
||
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,
|
||
LockedInDerived = al.LockedInDerived,
|
||
});
|
||
}
|
||
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,
|
||
MinTimeBetweenRuns = s.MinTimeBetweenRuns,
|
||
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds,
|
||
LockedInDerived = s.LockedInDerived,
|
||
});
|
||
}
|
||
foreach (var n in dto.NativeAlarmSources)
|
||
{
|
||
t.NativeAlarmSources.Add(new TemplateNativeAlarmSource(n.Name)
|
||
{
|
||
Description = n.Description,
|
||
ConnectionName = n.ConnectionName,
|
||
SourceReference = n.SourceReference,
|
||
ConditionFilter = n.ConditionFilter,
|
||
IsLocked = n.IsLocked,
|
||
IsInherited = n.IsInherited,
|
||
LockedInDerived = n.LockedInDerived,
|
||
});
|
||
}
|
||
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 — single-sourced through
|
||
// TemplateChildEquality. Compare against the DTO with its
|
||
// List value already normalised so an idempotent re-import of an
|
||
// old-form bundle doesn't spuriously report a Value change.
|
||
bool changed = !TemplateChildEquality.AttributesEqual(
|
||
current, attrDto with { Value = normalizedValue });
|
||
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;
|
||
current.LockedInDerived = attrDto.LockedInDerived;
|
||
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.LockedInDerived,
|
||
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,
|
||
LockedInDerived = attrDto.LockedInDerived,
|
||
};
|
||
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.LockedInDerived,
|
||
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);
|
||
|
||
// On-trigger script is referenced by name in the bundle; resolve the
|
||
// persisted FK back to a name over this template's scripts so the shared
|
||
// equality can compare it. Scripts are synced after alarms, so
|
||
// this reflects the pre-sync (current) script set — which is exactly the
|
||
// set current.OnTriggerScriptId points into.
|
||
var scriptNameById = TemplateChildEquality.ScriptNameResolver(ex.Scripts);
|
||
|
||
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))
|
||
{
|
||
// Single-sourced through TemplateChildEquality — includes
|
||
// the on-trigger script binding, which the diff also compares.
|
||
bool changed = !TemplateChildEquality.AlarmsEqual(current, alarmDto, scriptNameById);
|
||
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.LockedInDerived = alarmDto.LockedInDerived;
|
||
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,
|
||
current.LockedInDerived,
|
||
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,
|
||
LockedInDerived = alarmDto.LockedInDerived,
|
||
};
|
||
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,
|
||
newAlarm.LockedInDerived,
|
||
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))
|
||
{
|
||
// Single-sourced through TemplateChildEquality.
|
||
bool changed = !TemplateChildEquality.ScriptsEqual(current, scriptDto);
|
||
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;
|
||
current.MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns;
|
||
current.ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds;
|
||
current.LockedInDerived = scriptDto.LockedInDerived;
|
||
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,
|
||
current.LockedInDerived,
|
||
},
|
||
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,
|
||
MinTimeBetweenRuns = scriptDto.MinTimeBetweenRuns,
|
||
ExecutionTimeoutSeconds = scriptDto.ExecutionTimeoutSeconds,
|
||
LockedInDerived = scriptDto.LockedInDerived,
|
||
};
|
||
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,
|
||
newScript.LockedInDerived,
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Overwrite child sync (native alarm sources). Mirrors
|
||
/// <see cref="SyncTemplateAttributesAsync"/> for the
|
||
/// <c>NativeAlarmSources</c> collection: diffs the DTO's sources against the
|
||
/// existing template's sources by name and stages add / update / delete on
|
||
/// the tracked entity. Audit rows:
|
||
/// <c>TemplateNativeAlarmSourceAdded</c> / <c>…Updated</c> / <c>…Deleted</c>.
|
||
/// <para>
|
||
/// Update detection compares every writable field (Description,
|
||
/// ConnectionName, SourceReference, ConditionFilter, IsLocked, IsInherited,
|
||
/// LockedInDerived) — an idempotent overwrite produces no audit noise.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task SyncTemplateNativeAlarmSourcesAsync(
|
||
Template ex,
|
||
TemplateDto dto,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
var existingByName = ex.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||
var dtoByName = dto.NativeAlarmSources.ToDictionary(s => s.Name, s => s, StringComparer.Ordinal);
|
||
|
||
// Deletes — sources present on the target but not in the bundle.
|
||
foreach (var existing in existingByName.Values.ToList())
|
||
{
|
||
if (dtoByName.ContainsKey(existing.Name)) continue;
|
||
await _templateRepo.DeleteTemplateNativeAlarmSourceAsync(existing.Id, ct).ConfigureAwait(false);
|
||
ex.NativeAlarmSources.Remove(existing);
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"TemplateNativeAlarmSourceDeleted",
|
||
"TemplateNativeAlarmSource",
|
||
existing.Id.ToString(),
|
||
$"{ex.Name}.{existing.Name}",
|
||
new { TemplateName = ex.Name, SourceName = existing.Name },
|
||
ct).ConfigureAwait(false);
|
||
}
|
||
|
||
// Adds + Updates.
|
||
foreach (var srcDto in dto.NativeAlarmSources)
|
||
{
|
||
if (existingByName.TryGetValue(srcDto.Name, out var current))
|
||
{
|
||
// Single-sourced through TemplateChildEquality.
|
||
bool changed = !TemplateChildEquality.NativeAlarmSourcesEqual(current, srcDto);
|
||
if (!changed) continue;
|
||
|
||
current.Description = srcDto.Description;
|
||
current.ConnectionName = srcDto.ConnectionName;
|
||
current.SourceReference = srcDto.SourceReference;
|
||
current.ConditionFilter = srcDto.ConditionFilter;
|
||
current.IsLocked = srcDto.IsLocked;
|
||
current.IsInherited = srcDto.IsInherited;
|
||
current.LockedInDerived = srcDto.LockedInDerived;
|
||
await _templateRepo.UpdateTemplateNativeAlarmSourceAsync(current, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"TemplateNativeAlarmSourceUpdated",
|
||
"TemplateNativeAlarmSource",
|
||
current.Id.ToString(),
|
||
$"{ex.Name}.{current.Name}",
|
||
new
|
||
{
|
||
TemplateName = ex.Name,
|
||
SourceName = current.Name,
|
||
current.Description,
|
||
current.ConnectionName,
|
||
current.SourceReference,
|
||
current.ConditionFilter,
|
||
current.IsLocked,
|
||
current.IsInherited,
|
||
current.LockedInDerived,
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
}
|
||
else
|
||
{
|
||
var newSource = new TemplateNativeAlarmSource(srcDto.Name)
|
||
{
|
||
Description = srcDto.Description,
|
||
ConnectionName = srcDto.ConnectionName,
|
||
SourceReference = srcDto.SourceReference,
|
||
ConditionFilter = srcDto.ConditionFilter,
|
||
IsLocked = srcDto.IsLocked,
|
||
IsInherited = srcDto.IsInherited,
|
||
LockedInDerived = srcDto.LockedInDerived,
|
||
};
|
||
ex.NativeAlarmSources.Add(newSource);
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"TemplateNativeAlarmSourceAdded",
|
||
"TemplateNativeAlarmSource",
|
||
"0",
|
||
$"{ex.Name}.{newSource.Name}",
|
||
new
|
||
{
|
||
TemplateName = ex.Name,
|
||
SourceName = newSource.Name,
|
||
newSource.Description,
|
||
newSource.ConnectionName,
|
||
newSource.SourceReference,
|
||
newSource.ConditionFilter,
|
||
newSource.IsLocked,
|
||
newSource.IsInherited,
|
||
newSource.LockedInDerived,
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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>
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Second-pass rewire for template inheritance edges (<c>ParentTemplateId</c>),
|
||
/// mirroring <see cref="ResolveCompositionEdgesAsync"/>. The bundle carries the
|
||
/// base by name (<c>TemplateDto.BaseTemplateName</c>); the create/overwrite pass
|
||
/// never sets the FK, so without this pass a derived template lands as a root and
|
||
/// its inheritance is silently lost (arch-review C3). Runs after templates are
|
||
/// flushed so both the derived template and its base are resolvable by name.
|
||
/// Never throws — an unresolvable base leaves the edge null and records an audit
|
||
/// row, keeping the import all-or-nothing on real faults only.
|
||
/// </summary>
|
||
private async Task ResolveInheritanceEdgesAsync(
|
||
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;
|
||
|
||
var importedName = resolution.Action == ResolutionAction.Rename
|
||
? (resolution.RenameTo ?? dto.Name)
|
||
: dto.Name;
|
||
|
||
// The just-imported/updated template is tracked in the Local set.
|
||
var template = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||
string.Equals(t.Name, importedName, StringComparison.Ordinal));
|
||
if (template is null) continue;
|
||
|
||
// A root-template bundle — or an Overwrite that dropped the base —
|
||
// must clear any stale parent edge on the target row.
|
||
if (string.IsNullOrEmpty(dto.BaseTemplateName))
|
||
{
|
||
template.ParentTemplateId = null;
|
||
continue;
|
||
}
|
||
|
||
// Resolve the base template's PERSISTED name through the resolution
|
||
// map (it may have been renamed on import), then look it up by name —
|
||
// first in the tracked Local set (anything imported this run), then in
|
||
// the wider target DB (pre-existing rows not staged in this transaction).
|
||
var baseResolution = ResolveOrDefault(resolutionMap, "Template", dto.BaseTemplateName);
|
||
var baseName = baseResolution.Action == ResolutionAction.Rename
|
||
? (baseResolution.RenameTo ?? dto.BaseTemplateName)
|
||
: dto.BaseTemplateName;
|
||
|
||
var baseStub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||
string.Equals(t.Name, baseName, StringComparison.Ordinal));
|
||
int? baseId = baseStub?.Id;
|
||
if (baseId is null)
|
||
{
|
||
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
baseId = allTargets
|
||
.FirstOrDefault(t => string.Equals(t.Name, baseName, StringComparison.Ordinal))?.Id;
|
||
}
|
||
|
||
if (baseId is null)
|
||
{
|
||
// Unresolvable base (e.g. it was Skip-resolved and isn't in the
|
||
// target). Leave the edge null and record it; never throw.
|
||
template.ParentTemplateId = null;
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"BundleImportBaseTemplateUnresolved",
|
||
"Template",
|
||
"0",
|
||
importedName,
|
||
new
|
||
{
|
||
DerivedTemplateName = importedName,
|
||
UnresolvedBaseTemplateName = dto.BaseTemplateName,
|
||
ResolvedBaseName = baseName,
|
||
Reason = "Base template name not present in bundle or target.",
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
template.ParentTemplateId = baseId.Value;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Post-rewire acyclicity guard: reads the fully-merged template graph off the
|
||
/// change tracker (staged, not-yet-saved edges included) and rejects the import
|
||
/// if the bundle's inheritance or composition edges would persist a cycle —
|
||
/// e.g. an Overwrite that makes a base inherit from, or compose, its own
|
||
/// descendant. Delegates to the authoritative TemplateEngine
|
||
/// <see cref="CycleDetector"/>. Throws <see cref="SemanticValidationException"/>
|
||
/// on the first cycle found; the caller's catch rolls the whole import back.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The template list is taken from the change tracker rather than a fresh query
|
||
/// so it reflects the staged edges without a premature flush. It is complete for
|
||
/// cycle detection: any edge that references a pre-existing target template forces
|
||
/// a full <c>GetAllTemplatesAsync</c> load in the rewire passes (tracking every
|
||
/// template), and any all-imported cycle consists solely of tracked (Local)
|
||
/// templates — so every node of any candidate cycle is present.
|
||
/// </remarks>
|
||
private void EnsureNoTemplateGraphCycles()
|
||
{
|
||
var templates = _dbContext.ChangeTracker.Entries<Template>()
|
||
.Where(e => e.State != EntityState.Deleted)
|
||
.Select(e => e.Entity)
|
||
.ToList();
|
||
if (templates.Count == 0) return;
|
||
|
||
foreach (var t in templates)
|
||
{
|
||
if (t.ParentTemplateId.HasValue)
|
||
{
|
||
var cycle = CycleDetector.DetectInheritanceCycle(t.Id, t.ParentTemplateId.Value, templates);
|
||
if (cycle is not null)
|
||
{
|
||
throw new SemanticValidationException(new[]
|
||
{
|
||
$"Import would create a template inheritance cycle: {cycle}",
|
||
});
|
||
}
|
||
}
|
||
|
||
foreach (var comp in t.Compositions)
|
||
{
|
||
if (_dbContext.Entry(comp).State == EntityState.Deleted) continue;
|
||
var cycle = CycleDetector.DetectCompositionCycle(t.Id, comp.ComposedTemplateId, templates);
|
||
if (cycle is not null)
|
||
{
|
||
throw new SemanticValidationException(new[]
|
||
{
|
||
$"Import would create a template composition cycle: {cycle}",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
existing.MaxRetries = dto.MaxRetries;
|
||
existing.RetryDelay = dto.RetryDelay;
|
||
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,
|
||
MaxRetries = dto.MaxRetries,
|
||
RetryDelay = dto.RetryDelay,
|
||
};
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Second-pass rewire of the name-keyed <see cref="TemplateFolder.ParentFolderId"/>
|
||
/// edge. The Add pass creates folders parent-less (parent may be later in the
|
||
/// bundle and have no id yet); after the folder flush, resolve each folder's
|
||
/// bundle <c>ParentName</c> (through the rename resolution map) to a persisted
|
||
/// folder id and set the FK. Mirrors <see cref="ResolveInheritanceEdgesAsync"/>.
|
||
/// </summary>
|
||
private async Task ResolveFolderParentEdgesAsync(
|
||
IReadOnlyList<TemplateFolderDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
var allFolders = await _templateRepo.GetAllFoldersAsync(ct).ConfigureAwait(false);
|
||
var byName = allFolders.ToDictionary(f => f.Name, f => f, StringComparer.Ordinal);
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "TemplateFolder", dto.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
|
||
var childName = resolution.Action == ResolutionAction.Rename
|
||
? resolution.RenameTo ?? dto.Name
|
||
: dto.Name;
|
||
if (!byName.TryGetValue(childName, out var child)) continue;
|
||
|
||
int? parentId = null;
|
||
if (dto.ParentName is not null)
|
||
{
|
||
// A renamed parent is persisted under its RenameTo name.
|
||
var parentResolution = ResolveOrDefault(map, "TemplateFolder", dto.ParentName);
|
||
var parentName = parentResolution.Action == ResolutionAction.Rename
|
||
? parentResolution.RenameTo ?? dto.ParentName
|
||
: dto.ParentName;
|
||
if (byName.TryGetValue(parentName, out var parent))
|
||
{
|
||
parentId = parent.Id;
|
||
}
|
||
}
|
||
|
||
if (child.ParentFolderId != parentId)
|
||
{
|
||
child.ParentFolderId = parentId;
|
||
await _templateRepo.UpdateFolderAsync(child, ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Persists an added external system's method rows. <see cref="ExternalSystemMethod"/>
|
||
/// has no navigation on its parent definition, so an Add can't cascade its
|
||
/// methods the way template children do — they are added here after the parent
|
||
/// system has a materialised id from the central-config flush. Idempotent: a
|
||
/// method already present (e.g. a genuine Overwrite that synced its methods
|
||
/// inline) is skipped, so re-processing every non-Skip system is safe.
|
||
/// </summary>
|
||
private async Task PersistAddedExternalSystemMethodsAsync(
|
||
IReadOnlyList<ExternalSystemDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
if (dto.Methods.Count == 0) continue;
|
||
var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
|
||
var persistedName = resolution.Action == ResolutionAction.Rename
|
||
? resolution.RenameTo ?? dto.Name
|
||
: dto.Name;
|
||
var sys = await _externalRepo.GetExternalSystemByNameAsync(persistedName, ct).ConfigureAwait(false);
|
||
if (sys is null) continue;
|
||
|
||
var existing = await _externalRepo.GetMethodsByExternalSystemIdAsync(sys.Id, ct).ConfigureAwait(false);
|
||
var existingNames = existing.Select(m => m.Name).ToHashSet(StringComparer.Ordinal);
|
||
|
||
foreach (var methodDto in dto.Methods)
|
||
{
|
||
if (existingNames.Contains(methodDto.Name)) continue; // already synced (Overwrite) or already added.
|
||
var method = new ExternalSystemMethod(methodDto.Name, methodDto.HttpMethod, methodDto.Path)
|
||
{
|
||
ExternalSystemDefinitionId = sys.Id,
|
||
ParameterDefinitions = methodDto.ParameterDefinitions,
|
||
ReturnDefinition = methodDto.ReturnDefinition,
|
||
};
|
||
await _externalRepo.AddExternalSystemMethodAsync(method, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"ExternalSystemMethodAdded",
|
||
"ExternalSystemMethod",
|
||
"0",
|
||
$"{sys.Name}.{method.Name}",
|
||
new
|
||
{
|
||
ExternalSystemName = sys.Name,
|
||
MethodName = method.Name,
|
||
method.HttpMethod,
|
||
method.Path,
|
||
method.ParameterDefinitions,
|
||
method.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(BuildRecipient(r));
|
||
}
|
||
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(BuildRecipient(r));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
// Reconstruct a recipient carrying whichever contact(s) the bundle holds.
|
||
// Email-only and SMS-only round-trip exactly; a recipient with both keeps both.
|
||
// Build via the email ctor when an address is present (so the historical
|
||
// non-null-email path is unchanged) and additively restore the phone number;
|
||
// otherwise fall back to the SMS factory for phone-only recipients.
|
||
private static NotificationRecipient BuildRecipient(NotificationRecipientDto r)
|
||
{
|
||
if (r.EmailAddress is not null)
|
||
{
|
||
return new NotificationRecipient(r.Name, r.EmailAddress)
|
||
{
|
||
PhoneNumber = r.PhoneNumber,
|
||
};
|
||
}
|
||
|
||
return NotificationRecipient.ForSms(r.Name, r.PhoneNumber ?? string.Empty);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// SMS: mirrors ApplySmtpConfigsAsync exactly. SmsConfiguration is keyed by
|
||
// AccountSid (the diff engine's natural key — analogous to SMTP's Host), so a
|
||
// Rename targets AccountSid and Overwrite matches an existing config by AccountSid.
|
||
// The provider auth token is decrypted out of the SecretsBlock via the same path
|
||
// SMTP uses for its Credentials secret.
|
||
private async Task ApplySmsConfigsAsync(
|
||
IReadOnlyList<SmsConfigDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
var all = await _notificationRepo.GetAllSmsConfigurationsAsync(ct).ConfigureAwait(false);
|
||
var bySid = all.ToDictionary(s => s.AccountSid, s => s, StringComparer.Ordinal);
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "SmsConfiguration", dto.AccountSid);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var sid = resolution.RenameTo ?? dto.AccountSid;
|
||
var sms = BuildSms(dto, overrideAccountSid: sid);
|
||
await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sid,
|
||
new { sms.AccountSid, RenamedFrom = dto.AccountSid }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when bySid.TryGetValue(dto.AccountSid, out var ex):
|
||
ApplySmsFields(ex, dto);
|
||
await _notificationRepo.UpdateSmsConfigurationAsync(ex, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "SmsConfiguration", ex.Id.ToString(), ex.AccountSid,
|
||
new { ex.AccountSid }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var sms = BuildSms(dto, overrideAccountSid: null);
|
||
await _notificationRepo.AddSmsConfigurationAsync(sms, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SmsConfiguration", "0", sms.AccountSid,
|
||
new { sms.AccountSid }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static SmsConfiguration BuildSms(SmsConfigDto dto, string? overrideAccountSid)
|
||
{
|
||
var sms = new SmsConfiguration(overrideAccountSid ?? dto.AccountSid, dto.FromNumber);
|
||
ApplySmsFields(sms, dto);
|
||
return sms;
|
||
}
|
||
|
||
private static void ApplySmsFields(SmsConfiguration target, SmsConfigDto dto)
|
||
{
|
||
target.FromNumber = dto.FromNumber;
|
||
target.MessagingServiceSid = dto.MessagingServiceSid;
|
||
target.ApiBaseUrl = dto.ApiBaseUrl;
|
||
target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
|
||
target.MaxRetries = dto.MaxRetries;
|
||
target.RetryDelay = dto.RetryDelay;
|
||
target.AuthToken = dto.Secrets?.Values.TryGetValue("AuthToken", out var token) == true ? token : null;
|
||
}
|
||
|
||
// ApplyApiKeysAsync was removed: inbound API keys are not
|
||
// transported between environments, so a bundle never re-creates keys. Any keys
|
||
// present in a legacy 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 and the
|
||
// ApprovedApiKeyIds column was dropped with the SQL Server ApiKey
|
||
// entity: 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; the ApprovedApiKeyIds
|
||
// column that once carried them was dropped with the SQL Server ApiKey entity.
|
||
// 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,
|
||
};
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// 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 — the "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.
|
||
// 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;
|
||
}
|
||
|
||
// ---- 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>
|
||
/// 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)
|
||
{
|
||
// A binding may carry NO connection name — an unbound attribute, or a
|
||
// binding whose source connection was not included in the export. There
|
||
// is no meaningful target FK to write (DataConnectionId = 0 would be an
|
||
// invalid FK and FK-violate at commit on a relational provider), so skip
|
||
// the binding row entirely rather than persist a broken binding.
|
||
if (string.IsNullOrEmpty(b.ConnectionName))
|
||
{
|
||
continue;
|
||
}
|
||
// Resolve ConnectionName → target DataConnectionId. After Pass 2
|
||
// in ApplyDataConnectionsAsync, the map carries an entry for every
|
||
// referenced connection — carried in the bundle OR auto-matched in the
|
||
// target. A non-empty name that 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 an invalid FK.
|
||
if (!connectionMaps.IdBySourceRef.TryGetValue((sourceSiteIdentifier, b.ConnectionName), out var connId))
|
||
{
|
||
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).");
|
||
}
|
||
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>
|
||
/// 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> Errors, IReadOnlyList<string> Warnings)> RunSemanticValidationAsync(
|
||
BundleContentDto content,
|
||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||
CancellationToken ct)
|
||
{
|
||
var errors = new List<string>();
|
||
var warnings = new List<string>();
|
||
|
||
// ---- Pass 0: script trust gate ----
|
||
// Bundle import is the fifth script-trust call site. Every executable C#
|
||
// surface a bundle carries (template / shared / ApiMethod script bodies
|
||
// AND template script + alarm Expression-trigger bodies) is run through
|
||
// the shared ScriptTrustValidator BEFORE name resolution — a forbidden-API
|
||
// verdict is authoritative (not the false-positive-prone name heuristic),
|
||
// so it is a HARD error for all kinds, and it must not be masked by a
|
||
// name-resolution error. The verdict cache keeps repeat cost nil.
|
||
foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap))
|
||
{
|
||
IReadOnlyList<string> violations;
|
||
try
|
||
{
|
||
violations = ScriptTrustValidator.FindViolations(code);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Fail closed: a validator throw on one pathological body must not
|
||
// abort the whole import — treat it as a hard blocker FOR THAT
|
||
// script so the offender is named and the rest still surface.
|
||
_logger?.LogWarning(ex,
|
||
"Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}'; treating as a hard blocker.",
|
||
kind, entityName, scriptLabel);
|
||
errors.Add($"{kind} '{entityName}' script '{scriptLabel}': trust analysis failed to complete — rejected.");
|
||
continue;
|
||
}
|
||
foreach (var violation in violations)
|
||
{
|
||
errors.Add($"{kind} '{entityName}' script '{scriptLabel}': {violation}");
|
||
}
|
||
}
|
||
// Fail fast on trust violations: the operator sees trust errors OR name-
|
||
// resolution errors, not both at once. `warnings` is always empty here
|
||
// (Pass 0 emits only errors), so nothing surfacable is dropped.
|
||
if (errors.Count > 0) return (errors, warnings);
|
||
|
||
// N4: advisory warnings for instance overrides that target a LOCKED template
|
||
// member. Best-effort, non-blocking (rides ImportResult.Warnings); the row is
|
||
// still written (bundle fidelity) — the flattener is the behavioural authority.
|
||
var lockScanTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
foreach (var (_, message) in CollectLockedOverrideWarnings(content, resolutionMap, lockScanTemplates))
|
||
{
|
||
warnings.Add(message);
|
||
}
|
||
|
||
// ---- 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, keyed by ORIGIN (severity split).
|
||
// 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. Local-function / method declarations in a body
|
||
// are collected too so a script's own helper isn't flagged as missing.
|
||
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
|
||
var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
|
||
var locallyDeclaredInApiMethods = 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, referencedFromTemplates);
|
||
CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
|
||
}
|
||
foreach (var a in t.Attributes)
|
||
{
|
||
// Attribute.Value is a typed-literal default (never compiled), so it
|
||
// is not a trust surface; scanned only for the advisory name heuristic.
|
||
// 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, referencedFromTemplates);
|
||
CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
|
||
CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
|
||
}
|
||
|
||
// ApiMethod references genuinely unresolved (referenced there and NOT locally
|
||
// declared there) are hard errors (no downstream deploy gate); template-only
|
||
// unresolved references are advisory warnings (the deploy gate re-validates
|
||
// with a real compile). Local-declaration exclusion is per-origin so a
|
||
// template helper cannot mask a missing ApiMethod reference.
|
||
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
|
||
allCandidates.UnionWith(referencedFromApiMethods);
|
||
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
|
||
{
|
||
if (!LooksLikeResourceName(candidate)) continue;
|
||
if (KnownNonReferenceNames.Contains(candidate)) continue;
|
||
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
|
||
|
||
var apiUnresolved = referencedFromApiMethods.Contains(candidate)
|
||
&& !locallyDeclaredInApiMethods.Contains(candidate);
|
||
var templateUnresolved = referencedFromTemplates.Contains(candidate)
|
||
&& !locallyDeclaredInTemplates.Contains(candidate);
|
||
if (apiUnresolved)
|
||
{
|
||
errors.Add(
|
||
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
||
}
|
||
else if (templateUnresolved)
|
||
{
|
||
warnings.Add(
|
||
$"Template script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; re-validated at deploy time.");
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
// Warnings ride along regardless — they never gate the import.
|
||
if (errors.Count > 0) return (errors, warnings);
|
||
|
||
// ---- 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, warnings);
|
||
}
|
||
|
||
/// <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,
|
||
};
|
||
}
|
||
}
|