1774 lines
84 KiB
C#
1774 lines
84 KiB
C#
using System.Security.Cryptography;
|
||
using System.Text.Json;
|
||
using System.Text.Json.Serialization;
|
||
using Microsoft.Extensions.Options;
|
||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||
using ScadaLink.Commons.Entities.InboundApi;
|
||
using ScadaLink.Commons.Entities.Notifications;
|
||
using ScadaLink.Commons.Entities.Scripts;
|
||
using ScadaLink.Commons.Entities.Templates;
|
||
using ScadaLink.Commons.Interfaces.Repositories;
|
||
using ScadaLink.Commons.Interfaces.Services;
|
||
using ScadaLink.Commons.Interfaces.Transport;
|
||
using ScadaLink.Commons.Types.Transport;
|
||
using ScadaLink.Commons.Types.Enums;
|
||
using ScadaLink.Commons.Types.Flattening;
|
||
using ScadaLink.ConfigurationDatabase;
|
||
using ScadaLink.TemplateEngine.Validation;
|
||
using ScadaLink.Transport.Encryption;
|
||
using ScadaLink.Transport.Serialization;
|
||
|
||
namespace ScadaLink.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. Only LoadAsync is
|
||
/// implemented in this slice — the other two are wired into DI now so
|
||
/// follow-up tasks can fill them in without churning the constructor.
|
||
/// <para>
|
||
/// Audit-row responsibility: repository mutation methods in
|
||
/// <c>ScadaLink.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
|
||
{
|
||
private static readonly JsonSerializerOptions ContentJsonOptions = new()
|
||
{
|
||
WriteIndented = false,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||
Converters = { new JsonStringEnumConverter() },
|
||
};
|
||
|
||
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 ScadaLinkDbContext _dbContext;
|
||
private readonly ITemplateEngineRepository _templateRepo;
|
||
private readonly IExternalSystemRepository _externalRepo;
|
||
private readonly INotificationRepository _notificationRepo;
|
||
private readonly IInboundApiRepository _inboundApiRepo;
|
||
private readonly IBundleSessionStore _sessionStore;
|
||
private readonly IOptions<TransportOptions> _options;
|
||
private readonly TimeProvider _timeProvider;
|
||
private readonly SemanticValidator _semanticValidator;
|
||
|
||
public BundleImporter(
|
||
BundleSerializer bundleSerializer,
|
||
ManifestValidator manifestValidator,
|
||
BundleSecretEncryptor encryptor,
|
||
EntitySerializer entitySerializer,
|
||
IBundleSessionStore sessionStore,
|
||
IOptions<TransportOptions> options,
|
||
TimeProvider timeProvider,
|
||
ITemplateEngineRepository templateRepo,
|
||
IExternalSystemRepository externalRepo,
|
||
INotificationRepository notificationRepo,
|
||
IInboundApiRepository inboundApiRepo,
|
||
IAuditService auditService,
|
||
IAuditCorrelationContext correlationContext,
|
||
ScadaLinkDbContext dbContext,
|
||
SemanticValidator semanticValidator)
|
||
{
|
||
_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));
|
||
_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));
|
||
_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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the bundle envelope and opens a session keyed by a fresh GUID.
|
||
/// Wrong-passphrase failures surface as <see cref="CryptographicException"/>
|
||
/// so the caller (UI / API endpoint) can increment the lockout counter on
|
||
/// the returned session — this method does not mutate <c>FailedUnlockAttempts</c>
|
||
/// itself because the session does not exist yet at the point of failure.
|
||
/// </summary>
|
||
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.");
|
||
}
|
||
|
||
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. AES-GCM tag
|
||
// mismatch surfaces as a CryptographicException (or its
|
||
// AuthenticationTagMismatchException subclass on .NET 10+) — bubble it
|
||
// unchanged so the caller can detect wrong-passphrase via type check
|
||
// and increment the lockout counter on the (about-to-be-rejected)
|
||
// session reference. The session is not opened on the failure path.
|
||
byte[] decryptedContent;
|
||
if (manifest.Encryption is not null)
|
||
{
|
||
if (string.IsNullOrEmpty(passphrase))
|
||
{
|
||
throw new ArgumentException(
|
||
"Passphrase required for encrypted bundle.", nameof(passphrase));
|
||
}
|
||
decryptedContent = _encryptor.Decrypt(contentBytes, manifest.Encryption, passphrase);
|
||
}
|
||
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>
|
||
/// Diffs every artifact in the loaded bundle against the current target
|
||
/// database. Lookups are name-keyed (the bundle is portable across
|
||
/// environments so FK ids never line up). Emits <see cref="ConflictKind.Blocker"/>
|
||
/// items when a bundled template references a SharedScript or ExternalSystem
|
||
/// that is in neither the bundle nor the target — that import would crash at
|
||
/// runtime, so we surface it in the preview UI before Apply.
|
||
/// </summary>
|
||
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 ----
|
||
// Repos only expose GetTemplateByIdAsync / GetAllTemplatesAsync — no
|
||
// by-name lookup. Pull all once and index by name for the diff loop.
|
||
var allTemplates = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
var hydratedByName = new Dictionary<string, Template>(StringComparer.Ordinal);
|
||
foreach (var stub in allTemplates)
|
||
{
|
||
// GetAllTemplatesAsync may not eager-load children — fetch the
|
||
// children-loaded variant for any name that matches an incoming DTO
|
||
// so the per-child diff loop sees the full collection.
|
||
if (content.Templates.Any(t => string.Equals(t.Name, stub.Name, StringComparison.Ordinal)))
|
||
{
|
||
var hydrated = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
|
||
if (hydrated is not null)
|
||
{
|
||
hydratedByName[stub.Name] = hydrated;
|
||
}
|
||
}
|
||
}
|
||
foreach (var tDto in content.Templates)
|
||
{
|
||
hydratedByName.TryGetValue(tDto.Name, out var existing);
|
||
items.Add(_diff.CompareTemplate(tDto, existing));
|
||
}
|
||
|
||
// ---- SharedScripts ----
|
||
foreach (var s in content.SharedScripts)
|
||
{
|
||
var existing = await _templateRepo.GetSharedScriptByNameAsync(s.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareSharedScript(s, existing));
|
||
}
|
||
|
||
// ---- ExternalSystems (+ their methods) ----
|
||
foreach (var es in content.ExternalSystems)
|
||
{
|
||
var existing = await _externalRepo.GetExternalSystemByNameAsync(es.Name, ct).ConfigureAwait(false);
|
||
IReadOnlyList<Commons.Entities.ExternalSystems.ExternalSystemMethod>? methods = null;
|
||
if (existing is not null)
|
||
{
|
||
methods = await _externalRepo.GetMethodsByExternalSystemIdAsync(existing.Id, ct).ConfigureAwait(false);
|
||
}
|
||
items.Add(_diff.CompareExternalSystem(es, existing, methods));
|
||
}
|
||
|
||
// ---- DatabaseConnections ----
|
||
foreach (var db in content.DatabaseConnections)
|
||
{
|
||
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(db.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareDatabaseConnection(db, existing));
|
||
}
|
||
|
||
// ---- NotificationLists ----
|
||
foreach (var nl in content.NotificationLists)
|
||
{
|
||
var existing = await _notificationRepo.GetListByNameAsync(nl.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareNotificationList(nl, existing));
|
||
}
|
||
|
||
// ---- SmtpConfigurations (no by-host lookup — scan GetAll) ----
|
||
var allSmtp = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
|
||
var smtpByHost = allSmtp.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
|
||
foreach (var sm in content.SmtpConfigs)
|
||
{
|
||
smtpByHost.TryGetValue(sm.Host, out var existing);
|
||
items.Add(_diff.CompareSmtpConfiguration(sm, existing));
|
||
}
|
||
|
||
// ---- ApiKeys (no by-name lookup — scan GetAll) ----
|
||
var allApiKeys = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
|
||
var apiKeyByName = allApiKeys.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
|
||
foreach (var k in content.ApiKeys)
|
||
{
|
||
apiKeyByName.TryGetValue(k.Name, out var existing);
|
||
items.Add(_diff.CompareApiKey(k, existing));
|
||
}
|
||
|
||
// ---- ApiMethods ----
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
var existing = await _inboundApiRepo.GetMethodByNameAsync(m.Name, ct).ConfigureAwait(false);
|
||
items.Add(_diff.CompareApiMethod(m, existing));
|
||
}
|
||
|
||
// ---- Blocker detection ----
|
||
items.AddRange(await DetectBlockersAsync(content, ct).ConfigureAwait(false));
|
||
|
||
return new ImportPreview(sessionId, items);
|
||
}
|
||
|
||
/// <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, CancellationToken ct)
|
||
{
|
||
var blockers = new List<ImportPreviewItem>();
|
||
|
||
// Known-resolvable names = (in-bundle) ∪ (already-in-target).
|
||
var allSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||
var allExternalSystems = await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false);
|
||
|
||
var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var s in content.SharedScripts) sharedScriptNames.Add(s.Name);
|
||
foreach (var s in allSharedScripts) sharedScriptNames.Add(s.Name);
|
||
|
||
var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var e in content.ExternalSystems) externalSystemNames.Add(e.Name);
|
||
foreach (var e in allExternalSystems) externalSystemNames.Add(e.Name);
|
||
|
||
// Heuristic: collect a small candidate vocabulary of identifiers used
|
||
// by the bundle's scripts that are NOT one of the known-good names, and
|
||
// check whether each one was previously a SharedScript or ExternalSystem
|
||
// (i.e. matches the naming-convention shape of an identifier reference).
|
||
// For v1, we walk the SharedScripts the bundle *expects* — by scanning
|
||
// bodies and reporting any identifier-shaped token that resolves to a
|
||
// SharedScript by historical record... but that's overreach.
|
||
//
|
||
// Simpler + sufficient v1: scan every script body in the bundle's
|
||
// templates + ApiMethods, and for each occurrence of "Name(" where
|
||
// Name is a valid identifier, if Name appears in NEITHER set, surface
|
||
// it as a Blocker. This catches the documented use-case
|
||
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
|
||
var referencedFromBundle = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var t in content.Templates)
|
||
{
|
||
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referencedFromBundle);
|
||
foreach (var a in t.Attributes)
|
||
{
|
||
CollectCallIdentifiers(a.Value, referencedFromBundle);
|
||
CollectCallIdentifiers(a.DataSourceReference, referencedFromBundle);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
CollectCallIdentifiers(m.Script, referencedFromBundle);
|
||
}
|
||
|
||
// For each candidate, only report it as a blocker if it looks like a
|
||
// resource reference (PascalCase, length > 1) AND it's not present
|
||
// anywhere we can satisfy it. We deliberately do not look at language
|
||
// keywords or stdlib helpers — the test surface only ever uses
|
||
// well-named identifiers.
|
||
foreach (var candidate in referencedFromBundle.OrderBy(n => n, StringComparer.Ordinal))
|
||
{
|
||
if (!LooksLikeResourceName(candidate)) continue;
|
||
var isShared = sharedScriptNames.Contains(candidate);
|
||
var isExternal = externalSystemNames.Contains(candidate);
|
||
if (isShared || isExternal) continue;
|
||
|
||
blockers.Add(new ImportPreviewItem(
|
||
EntityType: "Reference",
|
||
Name: candidate,
|
||
ExistingVersion: null,
|
||
IncomingVersion: null,
|
||
Kind: ConflictKind.Blocker,
|
||
FieldDiffJson: null,
|
||
BlockerReason: $"References SharedScript or ExternalSystem '{candidate}' not present in bundle or target."));
|
||
}
|
||
|
||
return blockers;
|
||
}
|
||
|
||
private static void CollectCallIdentifiers(string? body, HashSet<string> sink)
|
||
{
|
||
if (string.IsNullOrEmpty(body)) return;
|
||
// Find every "Identifier(" or "Identifier." occurrence. The boundary
|
||
// before the identifier must NOT be an identifier char so we don't
|
||
// match the trailing portion of a longer token.
|
||
for (var i = 0; i < body.Length; i++)
|
||
{
|
||
if (!IsIdentifierStart(body[i])) continue;
|
||
if (i > 0 && IsIdentifierChar(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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
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>
|
||
/// Writes the bundle's artifacts to the central DB inside a single
|
||
/// transaction, threading <c>BundleImportId</c> through every audit row via
|
||
/// the scoped <see cref="IAuditCorrelationContext"/>.
|
||
/// <para>
|
||
/// Apply ordering — folders → templates → shared scripts → external systems
|
||
/// → database connections → notification lists → SMTP configs → API keys →
|
||
/// API methods — matches the dependency edges in the design doc so each
|
||
/// later category can resolve name-keyed references to earlier ones.
|
||
/// </para>
|
||
/// <para>
|
||
/// Semantic validation is two-tier: a minimal name-resolution scan first
|
||
/// (every script-callable identifier referenced by the merged target must
|
||
/// resolve to either a pre-existing or in-bundle <c>SharedScript</c> /
|
||
/// <c>ExternalSystem</c>), then — on Pass 1 success — the full
|
||
/// <see cref="TemplateEngine.Validation.SemanticValidator"/> over each
|
||
/// imported template scoped to its own single-template
|
||
/// <c>FlattenedConfiguration</c>. The minimal pass is run AGAINST the
|
||
/// merged target (incoming-bundle DTOs in memory plus the target DB read
|
||
/// inside the transaction) so a Skip resolution can legitimately fail
|
||
/// validation if it would have provided a missing dependency. The full
|
||
/// pass scopes to imported templates only — pre-existing untouched
|
||
/// templates aren't revalidated so a latent issue elsewhere in the
|
||
/// catalog doesn't block this import. See <see cref="RunSemanticValidationAsync"/>
|
||
/// for the per-pass contract.
|
||
/// </para>
|
||
/// <para>
|
||
/// Audit-row contract: every per-entity write goes through
|
||
/// <see cref="IAuditService.LogAsync"/> with the correlation context set to
|
||
/// <paramref name="sessionId"/>'s import id, plus a summary
|
||
/// <c>BundleImported</c> row inside the transaction. On failure the
|
||
/// transaction rolls back and a single <c>BundleImportFailed</c> row is
|
||
/// written OUTSIDE the rolled-back scope (correlation cleared first so the
|
||
/// row doesn't carry a non-existent import id).
|
||
/// </para>
|
||
/// </summary>
|
||
public async Task<ImportResult> ApplyAsync(
|
||
Guid sessionId,
|
||
IReadOnlyList<ImportResolution> resolutions,
|
||
string user,
|
||
CancellationToken ct = default)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(resolutions);
|
||
ArgumentNullException.ThrowIfNull(user);
|
||
|
||
var session = _sessionStore.Get(sessionId)
|
||
?? throw new InvalidOperationException($"Bundle session {sessionId} not found or expired.");
|
||
if (session.Locked)
|
||
{
|
||
throw new InvalidOperationException($"Bundle session {sessionId} is locked.");
|
||
}
|
||
|
||
BundleContentDto content;
|
||
try
|
||
{
|
||
content = JsonSerializer.Deserialize<BundleContentDto>(session.DecryptedContent, ContentJsonOptions)
|
||
?? throw new InvalidDataException("Session content deserialized to null.");
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
throw new InvalidDataException("Session content is not a valid BundleContentDto.", ex);
|
||
}
|
||
|
||
var bundleImportId = Guid.NewGuid();
|
||
var resolutionMap = resolutions.ToDictionary(
|
||
r => (r.EntityType, r.Name),
|
||
r => r);
|
||
var summary = new ImportSummary();
|
||
|
||
// Set the correlation BEFORE the transaction so any audit writes
|
||
// triggered during the apply pick up the BundleImportId — AuditService
|
||
// reads the scoped context at the moment LogAsync is called.
|
||
_correlationContext.BundleImportId = bundleImportId;
|
||
|
||
// BeginTransactionAsync is a no-op on the in-memory EF provider (which
|
||
// logs an InMemoryEventId.TransactionIgnoredWarning by default). To keep
|
||
// rollback semantics testable on in-memory AND correct on relational
|
||
// providers, we defer the SINGLE SaveChangesAsync call until just before
|
||
// CommitAsync — every Add*Async + LogAsync call only stages on the
|
||
// change tracker, so throwing before SaveChangesAsync naturally undoes
|
||
// the entire apply on both providers.
|
||
await using var tx = await _dbContext.Database.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// Run semantic validation FIRST — before any writes are staged.
|
||
// This is purely a name-resolution scan over the in-memory DTOs +
|
||
// pre-existing target reads, so it has no ordering dependency on
|
||
// the Apply* helpers. Failing here means the change tracker is
|
||
// still empty, which keeps the rollback contract simple on both
|
||
// the in-memory and relational providers (FU-B: intermediate
|
||
// SaveChangesAsync between Apply* and the second-pass rewire
|
||
// would otherwise prevent the in-memory provider from undoing
|
||
// already-flushed template rows on validation failure — the
|
||
// in-memory transaction is a no-op, so the only safe pattern is
|
||
// ONE deferred SaveChangesAsync at the very end of try-block).
|
||
//
|
||
// Skip-resolved DTOs are excluded from the in-bundle name set so
|
||
// a Skip on a dependency surfaces as a missing-reference error
|
||
// rather than silently passing.
|
||
var validationErrors = await RunSemanticValidationAsync(content, resolutionMap, ct).ConfigureAwait(false);
|
||
if (validationErrors.Count > 0)
|
||
{
|
||
throw new SemanticValidationException(validationErrors);
|
||
}
|
||
|
||
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 ApplyApiKeysAsync(content.ApiKeys, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||
await ApplyApiMethodsAsync(content.ApiMethods, resolutionMap, user, summary, ct).ConfigureAwait(false);
|
||
|
||
// FU-B / #39 + remainder of #37 — second-pass rewire of name-keyed
|
||
// FKs that can only be resolved AFTER every template's scripts and
|
||
// child rows have been staged. We flush here so Pass A can look up
|
||
// the just-persisted scripts by name and Pass B can look up the
|
||
// just-persisted templates by name; both passes stage further
|
||
// mutations that ride the SAME outer transaction (committed below).
|
||
//
|
||
// EF tracking note: AddAsync on the in-memory provider assigns
|
||
// synthetic ids eagerly, so this intermediate flush mostly
|
||
// materialises identity values on a relational provider. The
|
||
// rollback contract is preserved because semantic validation
|
||
// already ran above — any throw from this point onward represents
|
||
// a successful merge that the user wants to keep, and the only
|
||
// remaining failure surface is the final BundleImported audit
|
||
// write itself.
|
||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||
await ResolveAlarmScriptLinksAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||
await ResolveCompositionEdgesAsync(content.Templates, resolutionMap, user, ct).ConfigureAwait(false);
|
||
|
||
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,
|
||
},
|
||
},
|
||
cancellationToken: ct).ConfigureAwait(false);
|
||
|
||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||
|
||
_sessionStore.Remove(sessionId);
|
||
|
||
return new ImportResult(
|
||
BundleImportId: bundleImportId,
|
||
Added: summary.Added,
|
||
Overwritten: summary.Overwritten,
|
||
Skipped: summary.Skipped,
|
||
Renamed: summary.Renamed,
|
||
StaleInstanceIds: Array.Empty<int>(),
|
||
AuditEventCorrelation: bundleImportId.ToString());
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Rollback can itself throw (connection drop mid-rollback, provider
|
||
// bug, etc). If it does, we must STILL write the BundleImportFailed
|
||
// audit row — otherwise a rollback-failure path silently swallows
|
||
// the import's audit trail. Capture the rollback exception (if any)
|
||
// and surface it on the failure row alongside the original cause.
|
||
Exception? rollbackFailure = null;
|
||
try
|
||
{
|
||
await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch (Exception rbEx)
|
||
{
|
||
rollbackFailure = rbEx;
|
||
}
|
||
|
||
// If rollback threw the IDbContextTransaction is in an indeterminate
|
||
// state and still associated with the DbContext — a subsequent
|
||
// SaveChangesAsync would attempt to enlist in (or commit to) that
|
||
// broken transaction, and the failure-row would itself be rolled
|
||
// back when the transaction is finally disposed. Dispose it now so
|
||
// the audit-row write below uses a fresh implicit transaction. On
|
||
// the happy rollback path Dispose is a benign no-op (the using
|
||
// would call it on scope exit anyway).
|
||
if (rollbackFailure is not null)
|
||
{
|
||
try { await tx.DisposeAsync().ConfigureAwait(false); }
|
||
catch { /* dispose-after-throw must not mask the original cause */ }
|
||
}
|
||
|
||
// Clear the change tracker before writing the failure row — on the
|
||
// in-memory provider the rollback is a no-op and the staged adds
|
||
// would otherwise persist when the next SaveChangesAsync runs. This
|
||
// also matters when rollback threw: the change tracker is in an
|
||
// ambiguous state and we don't want the failure-write to sweep up
|
||
// any of the staged apply mutations.
|
||
_dbContext.ChangeTracker.Clear();
|
||
|
||
// Clear correlation FIRST so the failure row doesn't carry the now-
|
||
// rolled-back BundleImportId. The contract is: BundleImportFailed
|
||
// exists at top level (no correlation) so audit consumers can see
|
||
// imports that aborted before any rows landed.
|
||
_correlationContext.BundleImportId = null;
|
||
try
|
||
{
|
||
await _auditService.LogAsync(
|
||
user: user,
|
||
action: "BundleImportFailed",
|
||
entityType: "Bundle",
|
||
entityId: bundleImportId.ToString(),
|
||
entityName: session.Manifest.SourceEnvironment,
|
||
afterState: new
|
||
{
|
||
BundleImportId = bundleImportId,
|
||
Reason = ex.Message,
|
||
ExceptionType = ex.GetType().FullName,
|
||
RollbackException = rollbackFailure?.Message,
|
||
},
|
||
cancellationToken: ct).ConfigureAwait(false);
|
||
await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// Audit-write is best-effort per design §10 ("Audit-write failure
|
||
// NEVER aborts the user-facing action — audit is best-effort, the
|
||
// action's own success/failure path is authoritative"). Swallow
|
||
// any failure here so the original exception below propagates
|
||
// unchanged rather than being masked by an audit-layer fault.
|
||
}
|
||
throw;
|
||
}
|
||
finally
|
||
{
|
||
// Always clear — even on the success path the correlation only
|
||
// applies to the apply we just finished. Subsequent operations on
|
||
// this scope (e.g. a second concurrent apply on a circuit) must
|
||
// not inherit the import id.
|
||
_correlationContext.BundleImportId = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>Mutable per-apply counter struct, accumulated through every helper.</summary>
|
||
private sealed class ImportSummary
|
||
{
|
||
public int Added { get; set; }
|
||
public int Overwritten { get; set; }
|
||
public int Skipped { get; set; }
|
||
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);
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "Template", dto.Name);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var t = BuildTemplate(dto, overrideName: name);
|
||
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "Template", "0", name,
|
||
new { Name = name, dto.Description, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
|
||
ex.Description = dto.Description;
|
||
await _templateRepo.UpdateTemplateAsync(ex, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "Template", ex.Id.ToString(), ex.Name,
|
||
new { ex.Name, ex.Description }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var t = BuildTemplate(dto, overrideName: null);
|
||
await _templateRepo.AddTemplateAsync(t, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "Template", "0", t.Name,
|
||
new { t.Name, t.Description }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds a <see cref="Template"/> from a bundle DTO, copying attributes /
|
||
/// alarms / scripts. Two name-keyed FKs are NOT wired here because they
|
||
/// require post-flush identity values:
|
||
/// <list type="bullet">
|
||
/// <item><c>TemplateAlarm.OnTriggerScriptId</c> — points at a sibling
|
||
/// <c>TemplateScript</c>; resolved by <see cref="ResolveAlarmScriptLinksAsync"/>
|
||
/// once <c>SaveChangesAsync</c> has assigned script ids.</item>
|
||
/// <item><c>TemplateComposition.ComposedTemplateId</c> — points at any
|
||
/// other persisted <c>Template</c>; resolved by
|
||
/// <see cref="ResolveCompositionEdgesAsync"/> once all bundled templates
|
||
/// have been flushed and any pre-existing target templates can be joined
|
||
/// in by name.</item>
|
||
/// </list>
|
||
/// Both resolution passes run inside the same outer import transaction.
|
||
/// <paramref name="overrideName"/> supports the Rename resolution; pass
|
||
/// <c>null</c> to keep the DTO's original name. Renamed templates are
|
||
/// looked up by their <em>imported</em> name (i.e. <c>RenameTo</c>) when
|
||
/// the second pass resolves their alarm/composition FKs; however, bundle
|
||
/// DTOs that reference a renamed template by its <em>original</em> name
|
||
/// will still fall through to the unresolved-audit path — call sites are
|
||
/// not rewritten in v1.
|
||
/// </summary>
|
||
private static Template BuildTemplate(TemplateDto dto, string? overrideName)
|
||
{
|
||
var t = new Template(overrideName ?? dto.Name) { Description = dto.Description };
|
||
foreach (var a in dto.Attributes)
|
||
{
|
||
t.Attributes.Add(new TemplateAttribute(a.Name)
|
||
{
|
||
Value = a.Value,
|
||
DataType = a.DataType,
|
||
IsLocked = a.IsLocked,
|
||
Description = a.Description,
|
||
DataSourceReference = a.DataSourceReference,
|
||
});
|
||
}
|
||
foreach (var al in dto.Alarms)
|
||
{
|
||
t.Alarms.Add(new TemplateAlarm(al.Name)
|
||
{
|
||
Description = al.Description,
|
||
PriorityLevel = al.PriorityLevel,
|
||
TriggerType = al.TriggerType,
|
||
TriggerConfiguration = al.TriggerConfiguration,
|
||
IsLocked = al.IsLocked,
|
||
});
|
||
}
|
||
foreach (var s in dto.Scripts)
|
||
{
|
||
t.Scripts.Add(new TemplateScript(s.Name, s.Code)
|
||
{
|
||
TriggerType = s.TriggerType,
|
||
TriggerConfiguration = s.TriggerConfiguration,
|
||
ParameterDefinitions = s.ParameterDefinitions,
|
||
ReturnDefinition = s.ReturnDefinition,
|
||
IsLocked = s.IsLocked,
|
||
});
|
||
}
|
||
return t;
|
||
}
|
||
|
||
/// <summary>
|
||
/// FU-B / remainder of #37 — Pass A of the post-template-flush rewire.
|
||
/// For every imported template (Add / Overwrite / Rename) whose bundle DTO
|
||
/// carries any alarm with <c>OnTriggerScriptName</c>, look up the
|
||
/// persisted alarm + script by name on the same template and set the FK.
|
||
/// If the named script is missing from the imported template, leave the
|
||
/// FK null and emit a <c>BundleImportAlarmScriptUnresolved</c> audit row
|
||
/// (correlation context still carries <c>BundleImportId</c>).
|
||
/// </summary>
|
||
private async Task ResolveAlarmScriptLinksAsync(
|
||
IReadOnlyList<TemplateDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
|
||
// Resolve the imported template by its post-rename name. Skip-
|
||
// resolved DTOs were already filtered out above; everything else
|
||
// landed under either the DTO name or RenameTo.
|
||
var importedName = resolution.Action == ResolutionAction.Rename
|
||
? (resolution.RenameTo ?? dto.Name)
|
||
: dto.Name;
|
||
|
||
// Use the children-loaded variant so the alarm + script collections
|
||
// are populated — the unfiltered GetAllTemplatesAsync returns the
|
||
// tracked entities EF assigned ids to during the flush, but the
|
||
// navigation collections aren't necessarily included.
|
||
var stub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||
string.Equals(t.Name, importedName, StringComparison.Ordinal));
|
||
if (stub is null) continue;
|
||
var template = await _templateRepo.GetTemplateWithChildrenAsync(stub.Id, ct).ConfigureAwait(false);
|
||
if (template is null) continue;
|
||
|
||
foreach (var alarmDto in dto.Alarms)
|
||
{
|
||
if (string.IsNullOrEmpty(alarmDto.OnTriggerScriptName)) continue;
|
||
|
||
var alarm = template.Alarms.FirstOrDefault(a =>
|
||
string.Equals(a.Name, alarmDto.Name, StringComparison.Ordinal));
|
||
if (alarm is null) continue;
|
||
|
||
var script = template.Scripts.FirstOrDefault(s =>
|
||
string.Equals(s.Name, alarmDto.OnTriggerScriptName, StringComparison.Ordinal));
|
||
if (script is null)
|
||
{
|
||
// Unresolved — emit warning audit row, leave FK null.
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"BundleImportAlarmScriptUnresolved",
|
||
"TemplateAlarm",
|
||
alarm.Id.ToString(),
|
||
$"{template.Name}.{alarm.Name}",
|
||
new
|
||
{
|
||
TemplateName = template.Name,
|
||
AlarmName = alarm.Name,
|
||
UnresolvedScriptName = alarmDto.OnTriggerScriptName,
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
alarm.OnTriggerScriptId = script.Id;
|
||
await _templateRepo.UpdateTemplateAlarmAsync(alarm, ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// FU-B / #39 — Pass B of the post-template-flush rewire. For every
|
||
/// imported template (Add / Overwrite / Rename) whose bundle DTO carries
|
||
/// any <c>Compositions</c>, replace the persisted template's existing
|
||
/// composition rows with new ones whose <c>ComposedTemplateId</c> is
|
||
/// resolved from <c>ComposedTemplateName</c> by looking up the now-
|
||
/// persisted template (just-imported set first, then pre-existing target).
|
||
/// <para>
|
||
/// Overwrite semantics: the persisted template's existing composition
|
||
/// rows are CLEARED before re-adding from the bundle so an Overwrite
|
||
/// truly overwrites the composition graph and doesn't silently retain
|
||
/// stale edges that aren't in the bundle anymore.
|
||
/// </para>
|
||
/// <para>
|
||
/// When <c>ComposedTemplateName</c> cannot be resolved — most commonly
|
||
/// because the user chose Skip on the referenced template — we emit a
|
||
/// <c>BundleImportCompositionUnresolved</c> audit row and skip the edge.
|
||
/// We deliberately do NOT throw: a Skip-resolved dependency is a
|
||
/// legitimate operator choice, not an import-killing error.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task ResolveCompositionEdgesAsync(
|
||
IReadOnlyList<TemplateDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||
string user,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
if (dto.Compositions.Count == 0) continue;
|
||
|
||
var importedName = resolution.Action == ResolutionAction.Rename
|
||
? (resolution.RenameTo ?? dto.Name)
|
||
: dto.Name;
|
||
|
||
var parentStub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||
string.Equals(t.Name, importedName, StringComparison.Ordinal));
|
||
if (parentStub is null) continue;
|
||
var parent = await _templateRepo.GetTemplateWithChildrenAsync(parentStub.Id, ct).ConfigureAwait(false);
|
||
if (parent is null) continue;
|
||
|
||
// Clear existing rows (Overwrite-truly-overwrites). EF Core tracks
|
||
// the deletes via the navigation; explicit DeleteAsync ensures the
|
||
// change is staged even when the relational provider doesn't
|
||
// detect orphans automatically.
|
||
if (parent.Compositions.Count > 0)
|
||
{
|
||
var existingComps = parent.Compositions.ToList();
|
||
foreach (var existing in existingComps)
|
||
{
|
||
await _templateRepo.DeleteTemplateCompositionAsync(existing.Id, ct).ConfigureAwait(false);
|
||
}
|
||
parent.Compositions.Clear();
|
||
}
|
||
|
||
foreach (var compDto in dto.Compositions)
|
||
{
|
||
if (string.IsNullOrEmpty(compDto.ComposedTemplateName))
|
||
{
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"BundleImportCompositionUnresolved",
|
||
"TemplateComposition",
|
||
"0",
|
||
$"{parent.Name}.{compDto.InstanceName}",
|
||
new
|
||
{
|
||
OwnerTemplateName = parent.Name,
|
||
compDto.InstanceName,
|
||
UnresolvedComposedTemplateName = (string?)null,
|
||
Reason = "Composition DTO carried empty ComposedTemplateName.",
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
// Resolve the composed template by name — first against the
|
||
// tracked Local set (which includes anything just imported,
|
||
// pre-flush or post-flush), then against the wider target DB
|
||
// (pre-existing rows not staged in this transaction).
|
||
var composedStub = _dbContext.Templates.Local.FirstOrDefault(t =>
|
||
string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal));
|
||
int? composedId = composedStub?.Id;
|
||
if (composedId is null)
|
||
{
|
||
var allTargets = await _templateRepo.GetAllTemplatesAsync(ct).ConfigureAwait(false);
|
||
composedId = allTargets
|
||
.FirstOrDefault(t => string.Equals(t.Name, compDto.ComposedTemplateName, StringComparison.Ordinal))?.Id;
|
||
}
|
||
|
||
if (composedId is null)
|
||
{
|
||
await _auditService.LogAsync(
|
||
user,
|
||
"BundleImportCompositionUnresolved",
|
||
"TemplateComposition",
|
||
"0",
|
||
$"{parent.Name}.{compDto.InstanceName}",
|
||
new
|
||
{
|
||
OwnerTemplateName = parent.Name,
|
||
compDto.InstanceName,
|
||
UnresolvedComposedTemplateName = compDto.ComposedTemplateName,
|
||
Reason = "Composed template name not present in bundle or target.",
|
||
},
|
||
ct).ConfigureAwait(false);
|
||
continue;
|
||
}
|
||
|
||
var comp = new TemplateComposition(compDto.InstanceName)
|
||
{
|
||
TemplateId = parent.Id,
|
||
ComposedTemplateId = composedId.Value,
|
||
};
|
||
await _templateRepo.AddTemplateCompositionAsync(comp, ct).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task ApplySharedScriptsAsync(
|
||
IReadOnlyList<SharedScriptDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "SharedScript", dto.Name);
|
||
var existing = await _templateRepo.GetSharedScriptByNameAsync(dto.Name, ct).ConfigureAwait(false);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var s = new SharedScript(name, dto.Code)
|
||
{
|
||
ParameterDefinitions = dto.ParameterDefinitions,
|
||
ReturnDefinition = dto.ReturnDefinition,
|
||
};
|
||
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SharedScript", "0", name,
|
||
new { s.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when existing is not null:
|
||
existing.Code = dto.Code;
|
||
existing.ParameterDefinitions = dto.ParameterDefinitions;
|
||
existing.ReturnDefinition = dto.ReturnDefinition;
|
||
await _templateRepo.UpdateSharedScriptAsync(existing, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "SharedScript", existing.Id.ToString(), existing.Name,
|
||
new { existing.Name }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var s = new SharedScript(dto.Name, dto.Code)
|
||
{
|
||
ParameterDefinitions = dto.ParameterDefinitions,
|
||
ReturnDefinition = dto.ReturnDefinition,
|
||
};
|
||
await _templateRepo.AddSharedScriptAsync(s, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SharedScript", "0", s.Name,
|
||
new { s.Name }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task ApplyExternalSystemsAsync(
|
||
IReadOnlyList<ExternalSystemDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "ExternalSystem", dto.Name);
|
||
var existing = await _externalRepo.GetExternalSystemByNameAsync(dto.Name, ct).ConfigureAwait(false);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var sys = BuildExternalSystem(dto, overrideName: name);
|
||
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", name,
|
||
new { sys.Name, sys.EndpointUrl, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when existing is not null:
|
||
existing.EndpointUrl = dto.BaseUrl;
|
||
existing.AuthType = dto.AuthType;
|
||
existing.AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null;
|
||
await _externalRepo.UpdateExternalSystemAsync(existing, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "ExternalSystem", existing.Id.ToString(), existing.Name,
|
||
new { existing.Name, existing.EndpointUrl }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var sys = BuildExternalSystem(dto, overrideName: null);
|
||
await _externalRepo.AddExternalSystemAsync(sys, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "ExternalSystem", "0", sys.Name,
|
||
new { sys.Name, sys.EndpointUrl }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static ExternalSystemDefinition BuildExternalSystem(ExternalSystemDto dto, string? overrideName)
|
||
{
|
||
var sys = new ExternalSystemDefinition(overrideName ?? dto.Name, dto.BaseUrl, dto.AuthType)
|
||
{
|
||
AuthConfiguration = dto.Secrets?.Values.TryGetValue("AuthConfiguration", out var auth) == true ? auth : null,
|
||
};
|
||
return sys;
|
||
}
|
||
|
||
private async Task ApplyDatabaseConnectionsAsync(
|
||
IReadOnlyList<DatabaseConnectionDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "DatabaseConnection", dto.Name);
|
||
var existing = await _externalRepo.GetDatabaseConnectionByNameAsync(dto.Name, ct).ConfigureAwait(false);
|
||
var connStr = dto.Secrets?.Values.TryGetValue("ConnectionString", out var cs) == true ? cs : string.Empty;
|
||
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var c = new DatabaseConnectionDefinition(name, connStr)
|
||
{
|
||
MaxRetries = dto.MaxRetries,
|
||
RetryDelay = dto.RetryDelay,
|
||
};
|
||
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", name,
|
||
new { c.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when existing is not null:
|
||
existing.ConnectionString = connStr;
|
||
existing.MaxRetries = dto.MaxRetries;
|
||
existing.RetryDelay = dto.RetryDelay;
|
||
await _externalRepo.UpdateDatabaseConnectionAsync(existing, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "DatabaseConnection", existing.Id.ToString(), existing.Name,
|
||
new { existing.Name }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var c = new DatabaseConnectionDefinition(dto.Name, connStr)
|
||
{
|
||
MaxRetries = dto.MaxRetries,
|
||
RetryDelay = dto.RetryDelay,
|
||
};
|
||
await _externalRepo.AddDatabaseConnectionAsync(c, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "DatabaseConnection", "0", c.Name,
|
||
new { c.Name }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task ApplyNotificationListsAsync(
|
||
IReadOnlyList<NotificationListDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "NotificationList", dto.Name);
|
||
var existing = await _notificationRepo.GetListByNameAsync(dto.Name, ct).ConfigureAwait(false);
|
||
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var list = BuildNotificationList(dto, overrideName: name);
|
||
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "NotificationList", "0", name,
|
||
new { list.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when existing is not null:
|
||
existing.Type = dto.Type;
|
||
// Recipient sync is structural — clear + re-add. The repo
|
||
// mutates the navigation collection, EF tracks the delete.
|
||
existing.Recipients.Clear();
|
||
foreach (var r in dto.Recipients)
|
||
{
|
||
existing.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
|
||
}
|
||
await _notificationRepo.UpdateNotificationListAsync(existing, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "NotificationList", existing.Id.ToString(), existing.Name,
|
||
new { existing.Name, RecipientCount = existing.Recipients.Count }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var list = BuildNotificationList(dto, overrideName: null);
|
||
await _notificationRepo.AddNotificationListAsync(list, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "NotificationList", "0", list.Name,
|
||
new { list.Name, RecipientCount = list.Recipients.Count }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static NotificationList BuildNotificationList(NotificationListDto dto, string? overrideName)
|
||
{
|
||
var list = new NotificationList(overrideName ?? dto.Name) { Type = dto.Type };
|
||
foreach (var r in dto.Recipients)
|
||
{
|
||
list.Recipients.Add(new NotificationRecipient(r.Name, r.EmailAddress));
|
||
}
|
||
return list;
|
||
}
|
||
|
||
private async Task ApplySmtpConfigsAsync(
|
||
IReadOnlyList<SmtpConfigDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
var all = await _notificationRepo.GetAllSmtpConfigurationsAsync(ct).ConfigureAwait(false);
|
||
var byHost = all.ToDictionary(s => s.Host, s => s, StringComparer.Ordinal);
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
// SmtpConfiguration is keyed by Host in the diff engine — mirror
|
||
// that here so a Rename targets Host, not an arbitrary "name".
|
||
var resolution = ResolveOrDefault(map, "SmtpConfiguration", dto.Host);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var host = resolution.RenameTo ?? dto.Host;
|
||
var smtp = BuildSmtp(dto, overrideHost: host);
|
||
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", host,
|
||
new { smtp.Host, RenamedFrom = dto.Host }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when byHost.TryGetValue(dto.Host, out var ex):
|
||
ApplySmtpFields(ex, dto);
|
||
await _notificationRepo.UpdateSmtpConfigurationAsync(ex, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "SmtpConfiguration", ex.Id.ToString(), ex.Host,
|
||
new { ex.Host }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var smtp = BuildSmtp(dto, overrideHost: null);
|
||
await _notificationRepo.AddSmtpConfigurationAsync(smtp, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "SmtpConfiguration", "0", smtp.Host,
|
||
new { smtp.Host }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static SmtpConfiguration BuildSmtp(SmtpConfigDto dto, string? overrideHost)
|
||
{
|
||
var smtp = new SmtpConfiguration(overrideHost ?? dto.Host, dto.AuthType, dto.FromAddress);
|
||
ApplySmtpFields(smtp, dto);
|
||
return smtp;
|
||
}
|
||
|
||
private static void ApplySmtpFields(SmtpConfiguration target, SmtpConfigDto dto)
|
||
{
|
||
target.Port = dto.Port;
|
||
target.AuthType = dto.AuthType;
|
||
target.FromAddress = dto.FromAddress;
|
||
target.TlsMode = dto.TlsMode;
|
||
target.ConnectionTimeoutSeconds = dto.ConnectionTimeoutSeconds;
|
||
target.MaxConcurrentConnections = dto.MaxConcurrentConnections;
|
||
target.MaxRetries = dto.MaxRetries;
|
||
target.RetryDelay = dto.RetryDelay;
|
||
target.Credentials = dto.Secrets?.Values.TryGetValue("Credentials", out var cred) == true ? cred : null;
|
||
}
|
||
|
||
private async Task ApplyApiKeysAsync(
|
||
IReadOnlyList<ApiKeyDto> dtos,
|
||
Dictionary<(string, string), ImportResolution> map,
|
||
string user,
|
||
ImportSummary summary,
|
||
CancellationToken ct)
|
||
{
|
||
if (dtos.Count == 0) return;
|
||
var all = await _inboundApiRepo.GetAllApiKeysAsync(ct).ConfigureAwait(false);
|
||
var byName = all.ToDictionary(k => k.Name, k => k, StringComparer.Ordinal);
|
||
|
||
foreach (var dto in dtos)
|
||
{
|
||
var resolution = ResolveOrDefault(map, "ApiKey", dto.Name);
|
||
switch (resolution.Action)
|
||
{
|
||
case ResolutionAction.Skip:
|
||
summary.Skipped++;
|
||
break;
|
||
case ResolutionAction.Rename:
|
||
{
|
||
var name = resolution.RenameTo ?? dto.Name;
|
||
var key = ApiKey.FromHash(name, dto.KeyHash);
|
||
key.IsEnabled = dto.IsEnabled;
|
||
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "ApiKey", "0", name,
|
||
new { key.Name, RenamedFrom = dto.Name }, ct).ConfigureAwait(false);
|
||
summary.Renamed++;
|
||
break;
|
||
}
|
||
case ResolutionAction.Overwrite when byName.TryGetValue(dto.Name, out var ex):
|
||
ex.KeyHash = dto.KeyHash;
|
||
ex.IsEnabled = dto.IsEnabled;
|
||
await _inboundApiRepo.UpdateApiKeyAsync(ex, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Update", "ApiKey", ex.Id.ToString(), ex.Name,
|
||
new { ex.Name, ex.IsEnabled }, ct).ConfigureAwait(false);
|
||
summary.Overwritten++;
|
||
break;
|
||
case ResolutionAction.Add:
|
||
case ResolutionAction.Overwrite:
|
||
default:
|
||
{
|
||
var key = ApiKey.FromHash(dto.Name, dto.KeyHash);
|
||
key.IsEnabled = dto.IsEnabled;
|
||
await _inboundApiRepo.AddApiKeyAsync(key, ct).ConfigureAwait(false);
|
||
await _auditService.LogAsync(user, "Create", "ApiKey", "0", key.Name,
|
||
new { key.Name, key.IsEnabled }, ct).ConfigureAwait(false);
|
||
summary.Added++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
existing.ApprovedApiKeyIds = dto.ApprovedApiKeyIds;
|
||
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)
|
||
{
|
||
return new ApiMethod(overrideName ?? dto.Name, dto.Script)
|
||
{
|
||
ApprovedApiKeyIds = dto.ApprovedApiKeyIds,
|
||
ParameterDefinitions = dto.ParameterDefinitions,
|
||
ReturnDefinition = dto.ReturnDefinition,
|
||
TimeoutSeconds = dto.TimeoutSeconds,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Two-tier semantic validation run before any rows are flushed:
|
||
/// <list type="number">
|
||
/// <item><b>Pass 1 — minimal name-resolution scan.</b> Catches the
|
||
/// import-specific crash surface that the full <c>SemanticValidator</c>
|
||
/// can't see: identifier-shaped call targets in
|
||
/// <c>TemplateScript</c> / <c>ApiMethod</c> bodies that resolve to neither
|
||
/// an in-bundle nor a pre-existing target <c>SharedScript</c> /
|
||
/// <c>ExternalSystem</c>. Skip-resolved DTOs are excluded from the
|
||
/// in-bundle name set so a Skip that would have provided a dependency
|
||
/// surfaces here. Fails fast: if Pass 1 finds errors, Pass 2 is not run.</item>
|
||
/// <item><b>Pass 2 — full <see cref="SemanticValidator"/>.</b> For each
|
||
/// template being imported (Add / Overwrite / Rename — not Skip), build a
|
||
/// per-template <see cref="FlattenedConfiguration"/> directly from the DTO
|
||
/// (single-template scope — no inheritance / composition resolution, since
|
||
/// the inheritance chain is reconstructed only at deploy time) and invoke
|
||
/// the same validator the deployment pipeline uses. Errors from every
|
||
/// template are aggregated into one list so the operator sees the full
|
||
/// surface at once. SharedScripts are passed as <see cref="ResolvedScript"/>
|
||
/// values combining bundle + target so call-target checks resolve in either
|
||
/// direction.</item>
|
||
/// </list>
|
||
/// <para>
|
||
/// Per-template scoping is intentional: pre-existing target templates that
|
||
/// haven't been touched by this bundle aren't run through the validator —
|
||
/// otherwise a latent validation issue on an unrelated template (one the
|
||
/// operator isn't trying to import) would block the import.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task<IReadOnlyList<string>> RunSemanticValidationAsync(
|
||
BundleContentDto content,
|
||
Dictionary<(string, string), ImportResolution> resolutionMap,
|
||
CancellationToken ct)
|
||
{
|
||
var errors = new List<string>();
|
||
|
||
// ---- Pass 1: minimal name-resolution scan ----
|
||
|
||
// Build the known-resolvable set. For in-bundle entries, EXCLUDE the
|
||
// Skip-resolved names — those aren't being written, so they can't
|
||
// satisfy a downstream reference. Renamed entries register under both
|
||
// their original DTO name (so the script body in the bundle still
|
||
// resolves) AND the new name; the v1 import doesn't rewrite call sites.
|
||
var sharedScriptNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var s in content.SharedScripts)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
sharedScriptNames.Add(s.Name);
|
||
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
|
||
{
|
||
sharedScriptNames.Add(resolution.RenameTo);
|
||
}
|
||
}
|
||
var externalSystemNames = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var e in content.ExternalSystems)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "ExternalSystem", e.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
externalSystemNames.Add(e.Name);
|
||
if (resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo))
|
||
{
|
||
externalSystemNames.Add(resolution.RenameTo);
|
||
}
|
||
}
|
||
|
||
// Pre-existing target entries always count as resolvable.
|
||
var preExistingSharedScripts = await _templateRepo.GetAllSharedScriptsAsync(ct).ConfigureAwait(false);
|
||
foreach (var s in preExistingSharedScripts)
|
||
{
|
||
sharedScriptNames.Add(s.Name);
|
||
}
|
||
foreach (var e in await _externalRepo.GetAllExternalSystemsAsync(ct).ConfigureAwait(false))
|
||
{
|
||
externalSystemNames.Add(e.Name);
|
||
}
|
||
|
||
// Collect every identifier-shaped call target from the bundle's
|
||
// templates + api methods. We only check the bundle's bodies here
|
||
// (matching PreviewAsync's blocker scan); pre-existing target rows are
|
||
// assumed already validated when they were originally written.
|
||
var referenced = new HashSet<string>(StringComparer.Ordinal);
|
||
foreach (var t in content.Templates)
|
||
{
|
||
// Skip-resolved templates aren't being written, so their script
|
||
// references don't need to resolve.
|
||
var resolution = ResolveOrDefault(resolutionMap, "Template", t.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
foreach (var s in t.Scripts) CollectCallIdentifiers(s.Code, referenced);
|
||
foreach (var a in t.Attributes)
|
||
{
|
||
CollectCallIdentifiers(a.Value, referenced);
|
||
CollectCallIdentifiers(a.DataSourceReference, referenced);
|
||
}
|
||
}
|
||
foreach (var m in content.ApiMethods)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
CollectCallIdentifiers(m.Script, referenced);
|
||
}
|
||
|
||
foreach (var candidate in referenced.OrderBy(n => n, StringComparer.Ordinal))
|
||
{
|
||
if (!LooksLikeResourceName(candidate)) continue;
|
||
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
|
||
errors.Add(
|
||
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
|
||
}
|
||
|
||
// Fail fast — running the full validator over templates that already
|
||
// failed name resolution would produce duplicate / lower-quality errors
|
||
// (the missing identifier shows up there as "callee not found" too).
|
||
if (errors.Count > 0) return errors;
|
||
|
||
// ---- Pass 2: full SemanticValidator over imported templates ----
|
||
|
||
// Build the shared-script catalog the validator uses to resolve
|
||
// CallShared targets. Combine in-bundle (non-Skip) + pre-existing
|
||
// target — same resolution model as Pass 1's name set.
|
||
var sharedScripts = new List<ResolvedScript>();
|
||
foreach (var s in content.SharedScripts)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "SharedScript", s.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
var name = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
|
||
? resolution.RenameTo
|
||
: s.Name;
|
||
sharedScripts.Add(new ResolvedScript
|
||
{
|
||
CanonicalName = name,
|
||
Code = s.Code,
|
||
ParameterDefinitions = s.ParameterDefinitions,
|
||
ReturnDefinition = s.ReturnDefinition,
|
||
});
|
||
}
|
||
foreach (var s in preExistingSharedScripts)
|
||
{
|
||
// Pre-existing target wins on duplicate name only when the bundle
|
||
// didn't supply it; otherwise the bundle's version (the one about
|
||
// to be written) is the right signature surface to validate against.
|
||
if (sharedScripts.Any(rs => string.Equals(rs.CanonicalName, s.Name, StringComparison.Ordinal))) continue;
|
||
sharedScripts.Add(new ResolvedScript
|
||
{
|
||
CanonicalName = s.Name,
|
||
Code = s.Code,
|
||
ParameterDefinitions = s.ParameterDefinitions,
|
||
ReturnDefinition = s.ReturnDefinition,
|
||
});
|
||
}
|
||
|
||
foreach (var dto in content.Templates)
|
||
{
|
||
var resolution = ResolveOrDefault(resolutionMap, "Template", dto.Name);
|
||
if (resolution.Action == ResolutionAction.Skip) continue;
|
||
|
||
var importedName = resolution.Action == ResolutionAction.Rename && !string.IsNullOrEmpty(resolution.RenameTo)
|
||
? resolution.RenameTo
|
||
: dto.Name;
|
||
|
||
var config = BuildFlattenedConfigForValidation(dto, importedName);
|
||
var result = _semanticValidator.Validate(config, sharedScripts);
|
||
foreach (var entry in result.Errors)
|
||
{
|
||
errors.Add($"Template '{importedName}': {entry.Message}");
|
||
}
|
||
}
|
||
|
||
return errors;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds a <see cref="FlattenedConfiguration"/> for a single template DTO
|
||
/// — the validator's input contract. The bundle DTO carries only the
|
||
/// template's own attributes / alarms / scripts (no inheritance / no
|
||
/// composition resolution at design time), so the flattening here is a
|
||
/// straight 1:1 copy with the alarm on-trigger-script name carried through
|
||
/// as the canonical name (the same script's bare name, since composed
|
||
/// modules aren't expanded at import time). This is intentionally narrower
|
||
/// than the production <c>FlatteningService</c> pipeline, which needs a
|
||
/// concrete <c>Instance</c> plus site / connection context that doesn't
|
||
/// exist yet at design time. The deployment-time flatten will revalidate
|
||
/// against the full graph; this pass catches the same-template-scope
|
||
/// errors that operators would otherwise only hit at deploy time.
|
||
/// </summary>
|
||
private static FlattenedConfiguration BuildFlattenedConfigForValidation(TemplateDto dto, string templateName)
|
||
{
|
||
var attributes = new List<ResolvedAttribute>(dto.Attributes.Count);
|
||
foreach (var a in dto.Attributes)
|
||
{
|
||
attributes.Add(new ResolvedAttribute
|
||
{
|
||
CanonicalName = a.Name,
|
||
Value = a.Value,
|
||
DataType = a.DataType.ToString(),
|
||
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,
|
||
Source = "Template",
|
||
});
|
||
}
|
||
|
||
return new FlattenedConfiguration
|
||
{
|
||
InstanceUniqueName = templateName,
|
||
TemplateId = 0,
|
||
SiteId = 0,
|
||
AreaId = null,
|
||
Attributes = attributes,
|
||
Alarms = alarms,
|
||
Scripts = scripts,
|
||
Connections = null,
|
||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||
};
|
||
}
|
||
}
|