using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Communication; namespace ZB.MOM.WW.ScadaBridge.DeploymentManager; /// /// WP-7: System-wide artifact deployment. /// Broadcasts artifacts (shared scripts, external systems, notification lists, DB connections, /// data connections, and SMTP configurations) to all sites with per-site tracking. /// /// - Successful sites are NOT rolled back on other failures. /// - Failed sites are retryable individually. /// - 120s timeout per site. /// - Cross-site version skew is supported. /// public class ArtifactDeploymentService { private readonly ISiteRepository _siteRepo; private readonly IDeploymentManagerRepository _deploymentRepo; private readonly ITemplateEngineRepository _templateRepo; private readonly IExternalSystemRepository _externalSystemRepo; private readonly INotificationRepository _notificationRepo; private readonly CommunicationService _communicationService; private readonly IAuditService _auditService; private readonly DeploymentManagerOptions _options; private readonly ILogger _logger; /// /// Initializes a new instance of the ArtifactDeploymentService. /// /// Repository for site data. /// Repository for deployment records. /// Repository for templates. /// Repository for external systems. /// Repository for notifications. /// Service for communicating with sites. /// Service for audit logging. /// Deployment manager options. /// Logger instance. public ArtifactDeploymentService( ISiteRepository siteRepo, IDeploymentManagerRepository deploymentRepo, ITemplateEngineRepository templateRepo, IExternalSystemRepository externalSystemRepo, INotificationRepository notificationRepo, CommunicationService communicationService, IAuditService auditService, IOptions options, ILogger logger) { _siteRepo = siteRepo; _deploymentRepo = deploymentRepo; _templateRepo = templateRepo; _externalSystemRepo = externalSystemRepo; _notificationRepo = notificationRepo; _communicationService = communicationService; _auditService = auditService; _options = options.Value; _logger = logger; } /// /// Collects all artifact types from repositories and builds a /// scoped to a specific site's data connections. /// /// The DB id of the site whose data connections are collected. /// Cancellation token. /// /// DeploymentManager-010: the logical deployment id for this artifact deployment. All per-site /// commands of one call share this id so the audit log, /// UI summary, and persisted record correlate. When null a fresh id is minted (used by /// single-site retries). /// /// A deployment artifacts command for the site. /// /// DeploymentManager-023: this convenience overload runs the global artifact queries /// for a single site (used by ). The multi-site /// path hoists the global queries OUT of the /// per-site loop and calls the prefetched-globals overload to avoid the N+1 /// re-query of every system-wide artifact set per site. /// public async Task BuildDeployArtifactsCommandAsync( int siteId, CancellationToken cancellationToken = default, string? deploymentId = null) { var globals = await FetchGlobalArtifactsAsync(cancellationToken); return await BuildDeployArtifactsCommandAsync(siteId, globals, cancellationToken, deploymentId); } /// /// Builds a per-site using a previously-fetched /// snapshot of the global artifact sets (shared scripts, external systems + methods, /// DB connections, notification lists, SMTP configurations). Only the per-site /// data-connection query runs here. /// /// /// DeploymentManager-023: separating the global fetch from the per-site build lets /// issue the global queries exactly once across /// the whole multi-site sweep, eliminating the N+1 re-query of shared scripts, /// external systems, methods, DB connections, notification lists, and SMTP /// configurations. /// private async Task BuildDeployArtifactsCommandAsync( int siteId, GlobalArtifactSnapshot globals, CancellationToken cancellationToken, string? deploymentId) { var dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken); // Map data connections var dataConnectionArtifacts = dataConnections.Select(dc => new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).ToList(); return new DeployArtifactsCommand( deploymentId ?? Guid.NewGuid().ToString("N"), globals.SharedScripts, globals.ExternalSystems, globals.DatabaseConnections, globals.NotificationLists, dataConnectionArtifacts, globals.SmtpConfigurations, DateTimeOffset.UtcNow); } /// /// Fetches the system-wide artifact sets that are identical across every site — /// shared scripts, external systems (with their methods serialized in), database /// connections, notification lists, and SMTP configurations. Used by /// to pre-load once before the per-site loop. /// /// /// DeploymentManager-023: the per-site artifact build path previously re-issued /// every one of these queries per site (≈ 5·N + M·N round trips for N sites /// and M external systems). Hoisting them here drops that to a single fetch. /// private async Task FetchGlobalArtifactsAsync(CancellationToken cancellationToken) { var sharedScripts = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken); var externalSystems = await _externalSystemRepo.GetAllExternalSystemsAsync(cancellationToken); var dbConnections = await _externalSystemRepo.GetAllDatabaseConnectionsAsync(cancellationToken); var notificationLists = await _notificationRepo.GetAllNotificationListsAsync(cancellationToken); var smtpConfigurations = await _notificationRepo.GetAllSmtpConfigurationsAsync(cancellationToken); // Map shared scripts var scriptArtifacts = sharedScripts.Select(s => new SharedScriptArtifact(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition)).ToList(); // Map external systems (serialize methods per system) var externalSystemArtifacts = new List(); foreach (var es in externalSystems) { var methods = await _externalSystemRepo.GetMethodsByExternalSystemIdAsync(es.Id, cancellationToken); var methodsJson = methods.Count > 0 ? JsonSerializer.Serialize(methods.Select(m => new { m.Name, m.HttpMethod, m.Path, m.ParameterDefinitions, m.ReturnDefinition })) : null; externalSystemArtifacts.Add(new ExternalSystemArtifact( es.Name, es.EndpointUrl, es.AuthType, es.AuthConfiguration, methodsJson)); } // Map database connections var dbConnectionArtifacts = dbConnections.Select(d => new DatabaseConnectionArtifact(d.Name, d.ConnectionString, d.MaxRetries, d.RetryDelay)).ToList(); // Map notification lists var notificationListArtifacts = notificationLists.Select(nl => new NotificationListArtifact(nl.Name, nl.Recipients.Select(r => r.EmailAddress).ToList())).ToList(); // Map SMTP configurations — use Host as the artifact name (matches SQLite PK on site) var smtpArtifacts = smtpConfigurations.Select(smtp => new SmtpConfigurationArtifact( $"{smtp.Host}:{smtp.Port}", smtp.Host, smtp.Port, smtp.AuthType, smtp.FromAddress, smtp.Credentials, null, smtp.TlsMode)).ToList(); return new GlobalArtifactSnapshot( scriptArtifacts, externalSystemArtifacts, dbConnectionArtifacts, notificationListArtifacts, smtpArtifacts); } /// /// Bag of the global artifact sets that do not vary per site, captured once at /// the start of and reused for every per-site /// command build (DeploymentManager-023). /// private sealed record GlobalArtifactSnapshot( IReadOnlyList SharedScripts, IReadOnlyList ExternalSystems, IReadOnlyList DatabaseConnections, IReadOnlyList NotificationLists, IReadOnlyList SmtpConfigurations); /// /// Deploys artifacts to all sites. Builds a per-site command with that site's data connections. /// Returns per-site result matrix. /// /// The user initiating the deployment. /// Cancellation token. /// A summary of the artifact deployment results for all sites. public async Task> DeployToAllSitesAsync( string user, CancellationToken cancellationToken = default) { var sites = await _siteRepo.GetAllSitesAsync(cancellationToken); if (sites.Count == 0) return Result.Failure("No sites configured."); var deploymentId = Guid.NewGuid().ToString("N"); var perSiteResults = new Dictionary(); // DeploymentManager-023: hoist the system-wide artifact queries (shared scripts, // external systems + methods, DB connections, notification lists, SMTP configs) // OUT of the per-site loop so they run ONCE instead of once per site. Only // data connections legitimately vary per site, so they stay inside the loop. var globals = await FetchGlobalArtifactsAsync(cancellationToken); // Build per-site commands sequentially (DbContext is not thread-safe). // DeploymentManager-010: every per-site command carries the SAME logical // deploymentId, so the per-site commands, audit log, persisted record, // and UI summary all reference one id instead of N+1 unrelated GUIDs. var siteCommands = new Dictionary(); foreach (var site in sites) { siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync( site.Id, globals, cancellationToken, deploymentId); } // Deploy to each site in parallel with per-site timeout var tasks = sites.Select(async site => { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); var command = siteCommands[site.Id]; _logger.LogInformation( "Deploying artifacts to site {SiteId} ({SiteName}), deploymentId={DeploymentId}", site.SiteIdentifier, site.Name, command.DeploymentId); var response = await _communicationService.DeployArtifactsAsync( site.SiteIdentifier, command, cts.Token); return new SiteArtifactResult( site.SiteIdentifier, site.Name, response.Success, response.ErrorMessage); } catch (Exception ex) when (ex is TimeoutException or OperationCanceledException or TaskCanceledException) { _logger.LogWarning( "Artifact deployment to site {SiteId} timed out: {Error}", site.SiteIdentifier, ex.Message); return new SiteArtifactResult( site.SiteIdentifier, site.Name, false, $"Timeout: {ex.Message}"); } catch (Exception ex) { _logger.LogError(ex, "Artifact deployment to site {SiteId} failed", site.SiteIdentifier); return new SiteArtifactResult( site.SiteIdentifier, site.Name, false, ex.Message); } }).ToList(); var results = await Task.WhenAll(tasks); foreach (var result in results) { perSiteResults[result.SiteId] = result; } // Persist the system artifact deployment record. // DeploymentManager-010: SystemArtifactDeploymentRecord has no dedicated // DeploymentId column (adding one is a Commons/ConfigurationDatabase // schema change outside this module). The logical deploymentId is // embedded in the PerSiteStatus payload so the persisted record can be // correlated with the audit log and UI summary that report the same id. var record = new SystemArtifactDeploymentRecord("Artifacts", user) { DeployedAt = DateTimeOffset.UtcNow, PerSiteStatus = JsonSerializer.Serialize(new { DeploymentId = deploymentId, Sites = perSiteResults }) }; await _deploymentRepo.AddSystemArtifactDeploymentAsync(record, cancellationToken); await _deploymentRepo.SaveChangesAsync(cancellationToken); var summary = new ArtifactDeploymentSummary( deploymentId, results.ToList(), results.Count(r => r.Success), results.Count(r => !r.Success)); await _auditService.LogAsync(user, "DeployArtifacts", "SystemArtifact", deploymentId, "Artifacts", new { summary.SuccessCount, summary.FailureCount }, cancellationToken); return Result.Success(summary); } /// /// WP-7: Retry artifact deployment to a specific site that previously failed. /// /// The database identifier of the site. /// The site identifier string. /// The user initiating the retry. /// Cancellation token. /// The result of the retry operation for the site. public async Task> RetryForSiteAsync( int siteDbId, string siteIdentifier, string user, CancellationToken cancellationToken = default) { try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(_options.ArtifactDeploymentTimeoutPerSite); var command = await BuildDeployArtifactsCommandAsync(siteDbId, cts.Token); var response = await _communicationService.DeployArtifactsAsync(siteIdentifier, command, cts.Token); var result = new SiteArtifactResult(siteIdentifier, siteIdentifier, response.Success, response.ErrorMessage); await _auditService.LogAsync(user, "RetryArtifactDeployment", "SystemArtifact", command.DeploymentId, siteIdentifier, new { response.Success }, cancellationToken); return response.Success ? Result.Success(result) : Result.Failure(response.ErrorMessage ?? "Retry failed."); } catch (Exception ex) { return Result.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}"); } } } /// /// WP-7: Per-site result for artifact deployment. /// public record SiteArtifactResult( string SiteId, string SiteName, bool Success, string? ErrorMessage); /// /// WP-7: Summary of system-wide artifact deployment with per-site results. /// public record ArtifactDeploymentSummary( string DeploymentId, IReadOnlyList SiteResults, int SuccessCount, int FailureCount);