using System.Text.Json; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ScadaLink.Commons.Entities.Deployment; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Artifacts; using ScadaLink.Commons.Types; using ScadaLink.Communication; namespace ScadaLink.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; 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. /// public async Task BuildDeployArtifactsCommandAsync( int siteId, CancellationToken cancellationToken = default) { 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 dataConnections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, 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 data connections var dataConnectionArtifacts = dataConnections.Select(dc => new DataConnectionArtifact(dc.Name, dc.Protocol, dc.PrimaryConfiguration, dc.BackupConfiguration, dc.FailoverRetryCount)).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 DeployArtifactsCommand( Guid.NewGuid().ToString("N"), scriptArtifacts, externalSystemArtifacts, dbConnectionArtifacts, notificationListArtifacts, dataConnectionArtifacts, smtpArtifacts, DateTimeOffset.UtcNow); } /// /// Deploys artifacts to all sites. Builds a per-site command with that site's data connections. /// Returns per-site result matrix. /// 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(); // Build per-site commands sequentially (DbContext is not thread-safe) var siteCommands = new Dictionary(); foreach (var site in sites) { siteCommands[site.Id] = await BuildDeployArtifactsCommandAsync(site.Id, cancellationToken); } // 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 var record = new SystemArtifactDeploymentRecord("Artifacts", user) { DeployedAt = DateTimeOffset.UtcNow, PerSiteStatus = JsonSerializer.Serialize(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. /// 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);