7b0b9c7365
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
379 lines
17 KiB
C#
379 lines
17 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<ArtifactDeploymentService> _logger;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the ArtifactDeploymentService.
|
|
/// </summary>
|
|
/// <param name="siteRepo">Repository for site data.</param>
|
|
/// <param name="deploymentRepo">Repository for deployment records.</param>
|
|
/// <param name="templateRepo">Repository for templates.</param>
|
|
/// <param name="externalSystemRepo">Repository for external systems.</param>
|
|
/// <param name="notificationRepo">Repository for notifications.</param>
|
|
/// <param name="communicationService">Service for communicating with sites.</param>
|
|
/// <param name="auditService">Service for audit logging.</param>
|
|
/// <param name="options">Deployment manager options.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public ArtifactDeploymentService(
|
|
ISiteRepository siteRepo,
|
|
IDeploymentManagerRepository deploymentRepo,
|
|
ITemplateEngineRepository templateRepo,
|
|
IExternalSystemRepository externalSystemRepo,
|
|
INotificationRepository notificationRepo,
|
|
CommunicationService communicationService,
|
|
IAuditService auditService,
|
|
IOptions<DeploymentManagerOptions> options,
|
|
ILogger<ArtifactDeploymentService> logger)
|
|
{
|
|
_siteRepo = siteRepo;
|
|
_deploymentRepo = deploymentRepo;
|
|
_templateRepo = templateRepo;
|
|
_externalSystemRepo = externalSystemRepo;
|
|
_notificationRepo = notificationRepo;
|
|
_communicationService = communicationService;
|
|
_auditService = auditService;
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collects all artifact types from repositories and builds a <see cref="DeployArtifactsCommand"/>
|
|
/// scoped to a specific site's data connections.
|
|
/// </summary>
|
|
/// <param name="siteId">The DB id of the site whose data connections are collected.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <param name="deploymentId">
|
|
/// DeploymentManager-010: the logical deployment id for this artifact deployment. All per-site
|
|
/// commands of one <see cref="DeployToAllSitesAsync"/> call share this id so the audit log,
|
|
/// UI summary, and persisted record correlate. When <c>null</c> a fresh id is minted (used by
|
|
/// single-site retries).
|
|
/// </param>
|
|
/// <returns>A deployment artifacts command for the site.</returns>
|
|
/// <remarks>
|
|
/// DeploymentManager-023: this convenience overload runs the global artifact queries
|
|
/// for a single site (used by <see cref="RetryForSiteAsync"/>). The multi-site
|
|
/// <see cref="DeployToAllSitesAsync"/> 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.
|
|
/// </remarks>
|
|
public async Task<DeployArtifactsCommand> BuildDeployArtifactsCommandAsync(
|
|
int siteId,
|
|
CancellationToken cancellationToken = default,
|
|
string? deploymentId = null)
|
|
{
|
|
var globals = await FetchGlobalArtifactsAsync(cancellationToken);
|
|
return await BuildDeployArtifactsCommandAsync(siteId, globals, cancellationToken, deploymentId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds a per-site <see cref="DeployArtifactsCommand"/> 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// DeploymentManager-023: separating the global fetch from the per-site build lets
|
|
/// <see cref="DeployToAllSitesAsync"/> 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.
|
|
/// </remarks>
|
|
private async Task<DeployArtifactsCommand> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="DeployToAllSitesAsync"/> to pre-load once before the per-site loop.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private async Task<GlobalArtifactSnapshot> 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<ExternalSystemArtifact>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Bag of the global artifact sets that do not vary per site, captured once at
|
|
/// the start of <see cref="DeployToAllSitesAsync"/> and reused for every per-site
|
|
/// command build (DeploymentManager-023).
|
|
/// </summary>
|
|
private sealed record GlobalArtifactSnapshot(
|
|
IReadOnlyList<SharedScriptArtifact> SharedScripts,
|
|
IReadOnlyList<ExternalSystemArtifact> ExternalSystems,
|
|
IReadOnlyList<DatabaseConnectionArtifact> DatabaseConnections,
|
|
IReadOnlyList<NotificationListArtifact> NotificationLists,
|
|
IReadOnlyList<SmtpConfigurationArtifact> SmtpConfigurations);
|
|
|
|
/// <summary>
|
|
/// Deploys artifacts to all sites. Builds a per-site command with that site's data connections.
|
|
/// Returns per-site result matrix.
|
|
/// </summary>
|
|
/// <param name="user">The user initiating the deployment.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>A summary of the artifact deployment results for all sites.</returns>
|
|
public async Task<Result<ArtifactDeploymentSummary>> DeployToAllSitesAsync(
|
|
string user,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var sites = await _siteRepo.GetAllSitesAsync(cancellationToken);
|
|
if (sites.Count == 0)
|
|
return Result<ArtifactDeploymentSummary>.Failure("No sites configured.");
|
|
|
|
var deploymentId = Guid.NewGuid().ToString("N");
|
|
var perSiteResults = new Dictionary<string, SiteArtifactResult>();
|
|
|
|
// 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<int, DeployArtifactsCommand>();
|
|
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<ArtifactDeploymentSummary>.Success(summary);
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-7: Retry artifact deployment to a specific site that previously failed.
|
|
/// </summary>
|
|
/// <param name="siteDbId">The database identifier of the site.</param>
|
|
/// <param name="siteIdentifier">The site identifier string.</param>
|
|
/// <param name="user">The user initiating the retry.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The result of the retry operation for the site.</returns>
|
|
public async Task<Result<SiteArtifactResult>> 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<SiteArtifactResult>.Success(result)
|
|
: Result<SiteArtifactResult>.Failure(response.ErrorMessage ?? "Retry failed.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return Result<SiteArtifactResult>.Failure($"Retry failed for site {siteIdentifier}: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// WP-7: Per-site result for artifact deployment.
|
|
/// </summary>
|
|
public record SiteArtifactResult(
|
|
string SiteId,
|
|
string SiteName,
|
|
bool Success,
|
|
string? ErrorMessage);
|
|
|
|
/// <summary>
|
|
/// WP-7: Summary of system-wide artifact deployment with per-site results.
|
|
/// </summary>
|
|
public record ArtifactDeploymentSummary(
|
|
string DeploymentId,
|
|
IReadOnlyList<SiteArtifactResult> SiteResults,
|
|
int SuccessCount,
|
|
int FailureCount);
|