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);