Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.DeploymentManager/ArtifactDeploymentService.cs
T
Joseph Doherty 7b0b9c7365 refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
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.
2026-05-28 09:37:45 -04:00

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