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.
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
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);
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the central-side Deployment Manager.
|
||||
/// </summary>
|
||||
public class DeploymentManagerOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// WP-6: Timeout for a lifecycle command round-trip (disable, enable, delete).
|
||||
/// Applied as a linked-CTS deadline in <c>DeploymentService</c> so a hung or
|
||||
/// unreachable site does not hold the per-instance operation lock indefinitely.
|
||||
/// </summary>
|
||||
public TimeSpan LifecycleCommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>WP-7: Timeout per site for system-wide artifact deployment.</summary>
|
||||
public TimeSpan ArtifactDeploymentTimeoutPerSite { get; set; } = TimeSpan.FromSeconds(120);
|
||||
|
||||
/// <summary>WP-3: Timeout for acquiring an operation lock on an instance.</summary>
|
||||
public TimeSpan OperationLockTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
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.Entities.Instances;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Central-side deployment orchestration service.
|
||||
/// Coordinates the full deployment pipeline:
|
||||
/// 1. Validate instance state transition (WP-4)
|
||||
/// 2. Acquire per-instance operation lock (WP-3)
|
||||
/// 3. Flatten configuration via TemplateEngine (captures template state at time of flatten -- WP-16)
|
||||
/// 4. Validate flattened configuration
|
||||
/// 5. Compute revision hash and diff
|
||||
/// 6. Send DeployInstanceCommand to site via CommunicationService
|
||||
/// 7. Track deployment status with optimistic concurrency (WP-4)
|
||||
/// 8. Store deployed config snapshot (WP-8)
|
||||
/// 9. Audit log all actions
|
||||
///
|
||||
/// WP-2: Each deployment has a unique deployment ID (GUID) + revision hash.
|
||||
/// WP-16: Template state captured at flatten time -- last-write-wins on templates is safe.
|
||||
/// </summary>
|
||||
public class DeploymentService
|
||||
{
|
||||
private readonly IDeploymentManagerRepository _repository;
|
||||
private readonly ISiteRepository _siteRepository;
|
||||
private readonly IFlatteningPipeline _flatteningPipeline;
|
||||
private readonly CommunicationService _communicationService;
|
||||
private readonly OperationLockManager _lockManager;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly DiffService _diffService;
|
||||
private readonly IDeploymentStatusNotifier _statusNotifier;
|
||||
private readonly DeploymentManagerOptions _options;
|
||||
private readonly ILogger<DeploymentService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Prefix written to <see cref="DeploymentRecord.ErrorMessage"/> when a
|
||||
/// deployment fails because the site command timed out or was cancelled.
|
||||
/// Used by the query-before-redeploy trigger (DeploymentManager-006) to tell
|
||||
/// a timeout-induced failure apart from other deployment errors.
|
||||
/// </summary>
|
||||
private const string TimeoutFailurePrefix = "Communication failure:";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="DeploymentService"/> with all required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="repository">Repository for deployment manager data access.</param>
|
||||
/// <param name="siteRepository">Repository for site data access.</param>
|
||||
/// <param name="flatteningPipeline">Pipeline for flattening and validating template configurations.</param>
|
||||
/// <param name="communicationService">Service for cross-cluster communication with sites.</param>
|
||||
/// <param name="lockManager">Manager for per-instance operation locks.</param>
|
||||
/// <param name="auditService">Service for recording audit log entries.</param>
|
||||
/// <param name="diffService">Service for computing configuration diffs.</param>
|
||||
/// <param name="statusNotifier">Notifier for pushing deployment status changes to the UI.</param>
|
||||
/// <param name="options">Deployment manager configuration options.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public DeploymentService(
|
||||
IDeploymentManagerRepository repository,
|
||||
ISiteRepository siteRepository,
|
||||
IFlatteningPipeline flatteningPipeline,
|
||||
CommunicationService communicationService,
|
||||
OperationLockManager lockManager,
|
||||
IAuditService auditService,
|
||||
DiffService diffService,
|
||||
IDeploymentStatusNotifier statusNotifier,
|
||||
IOptions<DeploymentManagerOptions> options,
|
||||
ILogger<DeploymentService> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_siteRepository = siteRepository;
|
||||
_flatteningPipeline = flatteningPipeline;
|
||||
_communicationService = communicationService;
|
||||
_lockManager = lockManager;
|
||||
_auditService = auditService;
|
||||
_diffService = diffService;
|
||||
_statusNotifier = statusNotifier;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-006: raises a push notification that a deployment record's
|
||||
/// status was just persisted, so the Central UI deployment-status page can
|
||||
/// re-render over its SignalR circuit instead of polling. Called at every
|
||||
/// point a <see cref="DeploymentRecord"/> status is written.
|
||||
/// </summary>
|
||||
private void NotifyStatusChange(DeploymentRecord record) =>
|
||||
_statusNotifier.NotifyStatusChanged(
|
||||
new DeploymentStatusChange(record.DeploymentId, record.InstanceId, record.Status));
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the site's string identifier from the numeric DB ID.
|
||||
/// The communication layer routes by string identifier (e.g. "site-a"), not DB ID.
|
||||
///
|
||||
/// DeploymentManager-021: when the <see cref="Site"/> row is missing (FK was
|
||||
/// deleted, race with admin delete, DB inconsistency) the previous behaviour
|
||||
/// silently substituted the numeric id rendered as a string — every
|
||||
/// downstream `CommunicationService` call then failed with a confusing
|
||||
/// "unknown site" routing error that hid the real cause. Treat a missing
|
||||
/// site row as a hard validation failure: throw
|
||||
/// <see cref="InvalidOperationException"/> naming the unresolved id so the
|
||||
/// operator sees the actual problem. On the deploy path the existing
|
||||
/// try/catch turns this into a Failed deployment record with a clear
|
||||
/// message; lifecycle paths propagate it to the caller (CLI/UI) which
|
||||
/// surface it as an error to the operator.
|
||||
/// </summary>
|
||||
private async Task<string> ResolveSiteIdentifierAsync(int siteId, CancellationToken cancellationToken)
|
||||
{
|
||||
var site = await _siteRepository.GetSiteByIdAsync(siteId, cancellationToken);
|
||||
if (site == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Site with ID {siteId} not found; cannot resolve its SiteIdentifier for routing.");
|
||||
return site.SiteIdentifier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Deploy an instance to its site.
|
||||
/// WP-2: Generates unique deployment ID, computes revision hash.
|
||||
/// WP-4: Validates state transitions, uses optimistic concurrency.
|
||||
/// WP-5: Site-side apply is all-or-nothing (handled by DeploymentManagerActor).
|
||||
/// WP-8: Stores deployed config snapshot on success.
|
||||
/// WP-16: Captures template state at time of flatten.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The database ID of the instance to deploy.</param>
|
||||
/// <param name="user">The username initiating the deployment, recorded in the audit log.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<DeploymentRecord>> DeployInstanceAsync(
|
||||
int instanceId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Load instance
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<DeploymentRecord>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// WP-4: Validate state transition
|
||||
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
|
||||
if (transitionError != null)
|
||||
return Result<DeploymentRecord>.Failure(transitionError);
|
||||
|
||||
// WP-3: Acquire per-instance operation lock
|
||||
using var lockHandle = await _lockManager.AcquireAsync(
|
||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||
|
||||
// WP-2: Generate unique deployment ID
|
||||
var deploymentId = Guid.NewGuid().ToString("N");
|
||||
|
||||
// WP-1/16: Flatten configuration (captures template state at this point in time)
|
||||
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
|
||||
if (flattenResult.IsFailure)
|
||||
return Result<DeploymentRecord>.Failure($"Validation failed: {flattenResult.Error}");
|
||||
|
||||
var flattenedConfig = flattenResult.Value.Configuration;
|
||||
var revisionHash = flattenResult.Value.RevisionHash;
|
||||
var validationResult = flattenResult.Value.Validation;
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
var errors = string.Join("; ", validationResult.Errors.Select(e => e.Message));
|
||||
return Result<DeploymentRecord>.Failure($"Pre-deployment validation failed: {errors}");
|
||||
}
|
||||
|
||||
// Serialize for transmission (also the payload stored in the deployed
|
||||
// snapshot on success / reconciliation).
|
||||
var configJson = JsonSerializer.Serialize(flattenedConfig);
|
||||
|
||||
// DeploymentManager-006: query-the-site-before-redeploy idempotency.
|
||||
// If a prior deployment for this instance is stuck InProgress or Failed
|
||||
// due to a timeout, the site may have actually applied the config. Query
|
||||
// the site for its currently-applied revision before re-sending so a
|
||||
// duplicate deployment is not produced (design: "Deployment Identity &
|
||||
// Idempotency"). A clean prior Success or a fresh first-time deploy
|
||||
// skips this extra round-trip.
|
||||
var reconciled = await TryReconcileWithSiteAsync(
|
||||
instance, revisionHash, configJson, user, cancellationToken);
|
||||
if (reconciled != null)
|
||||
return Result<DeploymentRecord>.Success(reconciled);
|
||||
|
||||
// WP-4: Create the deployment record directly in InProgress.
|
||||
//
|
||||
// DeploymentManager-022: the previous code wrote the record as Pending,
|
||||
// then immediately updated it to InProgress with no work in between
|
||||
// (flattening, validation, and reconciliation all completed above). The
|
||||
// back-to-back write cost an extra SaveChangesAsync round-trip, an
|
||||
// extra IDeploymentStatusNotifier push (CentralUI-006 rendered a
|
||||
// Pending→InProgress flicker for ~ms), and an extra row-version bump
|
||||
// for nothing. The transient Pending slot carried no operational
|
||||
// meaning — it was set and immediately overwritten — so dropping it
|
||||
// collapses the start of the deploy into a single insert + notify.
|
||||
// InProgress remains the documented "sent to site, awaiting response"
|
||||
// state, set immediately before the round-trip below.
|
||||
var record = new DeploymentRecord(deploymentId, user)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
Status = DeploymentStatus.InProgress,
|
||||
RevisionHash = revisionHash,
|
||||
DeployedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await _repository.AddDeploymentRecordAsync(record, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
NotifyStatusChange(record);
|
||||
|
||||
try
|
||||
{
|
||||
// WP-1: Send to site via CommunicationService
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var command = new DeployInstanceCommand(
|
||||
deploymentId, instance.UniqueName, revisionHash, configJson, user, DateTimeOffset.UtcNow);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId}",
|
||||
deploymentId, instance.UniqueName, siteId);
|
||||
|
||||
var response = await _communicationService.DeployInstanceAsync(siteId, command, cancellationToken);
|
||||
|
||||
// WP-1: Update status based on site response.
|
||||
record.Status = response.Status;
|
||||
record.ErrorMessage = response.ErrorMessage;
|
||||
record.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// DeploymentManager-003: once the site has confirmed the apply,
|
||||
// commit the deployment record's terminal status BEFORE touching
|
||||
// instance state and the deployed-config snapshot. If a later write
|
||||
// (instance update / snapshot store) fails, the recorded fact that
|
||||
// the site succeeded must NOT be lost -- otherwise central reports a
|
||||
// non-Success record while the site is running the new config.
|
||||
await _repository.UpdateDeploymentRecordAsync(record, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
NotifyStatusChange(record);
|
||||
|
||||
if (response.Status == DeploymentStatus.Success)
|
||||
{
|
||||
// The site has applied the deployment. The post-success
|
||||
// persistence below is best-effort: a failure here must be
|
||||
// logged loudly for operator reconciliation but must not flip
|
||||
// the already-committed Success record back to Failed.
|
||||
await ApplyPostSuccessSideEffectsAsync(
|
||||
instance, deploymentId, revisionHash, configJson,
|
||||
forceEnabledState: true, cancellationToken);
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await _auditService.LogAsync(user, "Deploy", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { DeploymentId = deploymentId, Status = record.Status.ToString() },
|
||||
cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Deployment {DeploymentId} for instance {Instance}: {Status}",
|
||||
deploymentId, instance.UniqueName, record.Status);
|
||||
|
||||
return record.Status == DeploymentStatus.Success
|
||||
? Result<DeploymentRecord>.Success(record)
|
||||
: Result<DeploymentRecord>.Failure(
|
||||
$"Deployment failed: {response.ErrorMessage ?? "Unknown error"}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// DeploymentManager-001: any exception out of the try (timeout,
|
||||
// cancellation, transport, serialization, DB) must leave the
|
||||
// deployment record as Failed -- the design requires an interrupted
|
||||
// deployment to be treated as failed, never stuck in InProgress.
|
||||
//
|
||||
// DeploymentManager-002: the failure-status write must NOT use the
|
||||
// operation's cancellation token. If the operation was cancelled or
|
||||
// timed out, that token is already cancelled and the cleanup writes
|
||||
// would themselves throw before the Failed status is persisted.
|
||||
// Use CancellationToken.None so the failure is durably recorded.
|
||||
var isTimeout = ex is TimeoutException or OperationCanceledException;
|
||||
|
||||
record.Status = DeploymentStatus.Failed;
|
||||
record.ErrorMessage = isTimeout
|
||||
? $"{TimeoutFailurePrefix} {ex.Message}"
|
||||
: $"Deployment error: {ex.Message}";
|
||||
record.CompletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await _repository.UpdateDeploymentRecordAsync(record, CancellationToken.None);
|
||||
await _repository.SaveChangesAsync(CancellationToken.None);
|
||||
NotifyStatusChange(record);
|
||||
|
||||
await _auditService.LogAsync(user, "DeployFailed", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { DeploymentId = deploymentId, Error = ex.Message },
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception cleanupEx)
|
||||
{
|
||||
// The deployment already failed; a failed cleanup write must not
|
||||
// mask the original error. Log loudly so an operator can reconcile.
|
||||
_logger.LogError(cleanupEx,
|
||||
"Failed to persist Failed status for deployment {DeploymentId} of instance {Instance} " +
|
||||
"after deployment error: {Error}",
|
||||
deploymentId, instance.UniqueName, ex.Message);
|
||||
}
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Deployment {DeploymentId} for instance {Instance} failed",
|
||||
deploymentId, instance.UniqueName);
|
||||
|
||||
return Result<DeploymentRecord>.Failure(
|
||||
isTimeout
|
||||
? $"Deployment timed out: {ex.Message}"
|
||||
: $"Deployment failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: Disable an instance. Stops Instance Actor, retains config, S&F drains.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The database ID of the instance to disable.</param>
|
||||
/// <param name="user">The username initiating the operation, recorded in the audit log.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<InstanceLifecycleResponse>> DisableInstanceAsync(
|
||||
int instanceId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceLifecycleResponse>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "disable");
|
||||
if (transitionError != null)
|
||||
return Result<InstanceLifecycleResponse>.Failure(transitionError);
|
||||
|
||||
using var lockHandle = await _lockManager.AcquireAsync(
|
||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||
|
||||
var commandId = Guid.NewGuid().ToString("N");
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var command = new DisableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||
|
||||
// WP-6: bound the round-trip with the configured lifecycle timeout so a
|
||||
// hung/unreachable site does not block the operation lock indefinitely.
|
||||
InstanceLifecycleResponse response;
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.LifecycleCommandTimeout);
|
||||
response = await _communicationService.DisableInstanceAsync(siteId, command, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
|
||||
{
|
||||
// DeploymentManager-019: a lifecycle command timeout produced no
|
||||
// audit row pre-fix — the operator saw a timeout in the UI but
|
||||
// the audit trail showed nothing happened, contrary to the
|
||||
// design's "audit logging for all instance lifecycle changes"
|
||||
// rule. Mirror the DeployFailed pattern: write a "<Action>TimedOut"
|
||||
// entry with CancellationToken.None so a cancelled outer token
|
||||
// (the typical reason this catch ran) cannot prevent the
|
||||
// durable audit write.
|
||||
await TryLogLifecycleTimeoutAsync(
|
||||
user, "DisableTimedOut", instanceId, instance.UniqueName, commandId, ex);
|
||||
|
||||
_logger.LogWarning(ex, "Disable of instance {Instance} timed out", instance.UniqueName);
|
||||
return Result<InstanceLifecycleResponse>.Failure(
|
||||
$"Disable failed: the site did not respond within {_options.LifecycleCommandTimeout}.");
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
instance.State = InstanceState.Disabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Disable", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
return response.Success
|
||||
? Result<InstanceLifecycleResponse>.Success(response)
|
||||
: Result<InstanceLifecycleResponse>.Failure(response.ErrorMessage ?? "Disable failed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: Enable an instance. Re-creates Instance Actor from stored config.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The database ID of the instance to enable.</param>
|
||||
/// <param name="user">The username initiating the operation, recorded in the audit log.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<InstanceLifecycleResponse>> EnableInstanceAsync(
|
||||
int instanceId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceLifecycleResponse>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "enable");
|
||||
if (transitionError != null)
|
||||
return Result<InstanceLifecycleResponse>.Failure(transitionError);
|
||||
|
||||
using var lockHandle = await _lockManager.AcquireAsync(
|
||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||
|
||||
var commandId = Guid.NewGuid().ToString("N");
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var command = new EnableInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||
|
||||
// WP-6: bound the round-trip with the configured lifecycle timeout.
|
||||
InstanceLifecycleResponse response;
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.LifecycleCommandTimeout);
|
||||
response = await _communicationService.EnableInstanceAsync(siteId, command, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
|
||||
{
|
||||
// DeploymentManager-019: emit an audit entry on lifecycle timeout
|
||||
// so the operator's attempted Enable is recorded; see the matching
|
||||
// comment in DisableInstanceAsync for the full rationale.
|
||||
await TryLogLifecycleTimeoutAsync(
|
||||
user, "EnableTimedOut", instanceId, instance.UniqueName, commandId, ex);
|
||||
|
||||
_logger.LogWarning(ex, "Enable of instance {Instance} timed out", instance.UniqueName);
|
||||
return Result<InstanceLifecycleResponse>.Failure(
|
||||
$"Enable failed: the site did not respond within {_options.LifecycleCommandTimeout}.");
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
instance.State = InstanceState.Enabled;
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Enable", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
return response.Success
|
||||
? Result<InstanceLifecycleResponse>.Success(response)
|
||||
: Result<InstanceLifecycleResponse>.Failure(response.ErrorMessage ?? "Enable failed.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: Delete an instance. Stops the site actor, removes site config, and
|
||||
/// removes the central instance record (deployment history, snapshot,
|
||||
/// overrides, and connection bindings go with it). S&F NOT cleared.
|
||||
/// Delete fails if the site is unreachable within
|
||||
/// <c>CommunicationOptions.LifecycleTimeout</c> (applied inside
|
||||
/// <see cref="CommunicationService.DeleteInstanceAsync"/>).
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The database ID of the instance to delete.</param>
|
||||
/// <param name="user">The username initiating the deletion, recorded in the audit log.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<InstanceLifecycleResponse>> DeleteInstanceAsync(
|
||||
int instanceId,
|
||||
string user,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<InstanceLifecycleResponse>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "delete");
|
||||
if (transitionError != null)
|
||||
return Result<InstanceLifecycleResponse>.Failure(transitionError);
|
||||
|
||||
using var lockHandle = await _lockManager.AcquireAsync(
|
||||
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
|
||||
|
||||
var commandId = Guid.NewGuid().ToString("N");
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
|
||||
|
||||
// WP-6: bound the round-trip with the configured lifecycle timeout.
|
||||
InstanceLifecycleResponse response;
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_options.LifecycleCommandTimeout);
|
||||
response = await _communicationService.DeleteInstanceAsync(siteId, command, cts.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
|
||||
{
|
||||
// DeploymentManager-019: emit an audit entry on lifecycle timeout
|
||||
// so the operator's attempted Delete is recorded; see the matching
|
||||
// comment in DisableInstanceAsync for the full rationale.
|
||||
await TryLogLifecycleTimeoutAsync(
|
||||
user, "DeleteTimedOut", instanceId, instance.UniqueName, commandId, ex);
|
||||
|
||||
_logger.LogWarning(ex, "Delete of instance {Instance} timed out", instance.UniqueName);
|
||||
return Result<InstanceLifecycleResponse>.Failure(
|
||||
$"Delete failed: the site did not respond within {_options.LifecycleCommandTimeout}.");
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
// Delete means delete: remove the instance record entirely.
|
||||
// Deployment records, snapshot, overrides, and connection bindings
|
||||
// are removed with it (see repository implementation).
|
||||
//
|
||||
// DeploymentManager-004: the site has already destroyed the Instance
|
||||
// Actor and removed its config. If the central record removal now
|
||||
// fails (DB error / concurrency), the exception must NOT escape
|
||||
// uncaught -- that would leave the central record orphaned and
|
||||
// un-deletable through the normal path (a re-issued delete may fail
|
||||
// because the site no longer has the instance). Surface a distinct
|
||||
// failure so an operator can reconcile.
|
||||
try
|
||||
{
|
||||
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Instance {Instance} was deleted at the site, but the central record could not be " +
|
||||
"removed -- the central record is now orphaned and must be reconciled manually",
|
||||
instance.UniqueName);
|
||||
|
||||
await _auditService.LogAsync(user, "DeleteOrphaned", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, Error = ex.Message },
|
||||
CancellationToken.None);
|
||||
|
||||
return Result<InstanceLifecycleResponse>.Failure(
|
||||
$"The site deleted instance '{instance.UniqueName}', but the central record could not " +
|
||||
$"be removed: {ex.Message}. The central record is orphaned and must be reconciled.");
|
||||
}
|
||||
}
|
||||
|
||||
await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(),
|
||||
instance.UniqueName, new { CommandId = commandId, response.Success },
|
||||
cancellationToken);
|
||||
|
||||
return response.Success
|
||||
? Result<InstanceLifecycleResponse>.Success(response)
|
||||
: Result<InstanceLifecycleResponse>.Failure(
|
||||
response.ErrorMessage ?? "Delete failed. Site may be unreachable.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Get the deployed config snapshot and compare with current
|
||||
/// template-derived state. Produces both a staleness flag and — per the
|
||||
/// design's "Diff View" — a structured <see cref="ConfigurationDiff"/> of
|
||||
/// added/removed/changed attributes, alarms, and scripts (including data
|
||||
/// connection binding changes) computed by the TemplateEngine
|
||||
/// <see cref="DiffService"/>.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">The database ID of the instance to compare.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<Result<DeploymentComparisonResult>> GetDeploymentComparisonAsync(
|
||||
int instanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await _repository.GetDeployedSnapshotByInstanceIdAsync(instanceId, cancellationToken);
|
||||
if (snapshot == null)
|
||||
return Result<DeploymentComparisonResult>.Failure("No deployed snapshot found for this instance.");
|
||||
|
||||
// Compute current template-derived config
|
||||
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
|
||||
if (currentResult.IsFailure)
|
||||
return Result<DeploymentComparisonResult>.Failure($"Cannot compute current config: {currentResult.Error}");
|
||||
|
||||
var currentConfig = currentResult.Value.Configuration;
|
||||
var currentHash = currentResult.Value.RevisionHash;
|
||||
var isStale = snapshot.RevisionHash != currentHash;
|
||||
|
||||
// DeploymentManager-007: deserialize the deployed snapshot and run the
|
||||
// TemplateEngine DiffService so the result carries real
|
||||
// added/removed/changed detail, not just a hash comparison. A snapshot
|
||||
// that cannot be deserialized (corrupt / older schema) still yields the
|
||||
// hash-based staleness result, with a null diff.
|
||||
ConfigurationDiff? diff = null;
|
||||
try
|
||||
{
|
||||
var deployedConfig = JsonSerializer.Deserialize<FlattenedConfiguration>(snapshot.ConfigurationJson);
|
||||
if (deployedConfig != null)
|
||||
{
|
||||
diff = _diffService.ComputeDiff(
|
||||
deployedConfig, currentConfig, snapshot.RevisionHash, currentHash);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Deployed snapshot for instance {InstanceId} deserialized to null; " +
|
||||
"returning hash-based comparison without a structured diff",
|
||||
instanceId);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not deserialize deployed snapshot for instance {InstanceId}; " +
|
||||
"returning hash-based comparison without a structured diff",
|
||||
instanceId);
|
||||
}
|
||||
|
||||
var result = new DeploymentComparisonResult(
|
||||
instanceId,
|
||||
snapshot.RevisionHash,
|
||||
currentHash,
|
||||
isStale,
|
||||
snapshot.DeployedAt,
|
||||
diff);
|
||||
|
||||
return Result<DeploymentComparisonResult>.Success(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Returns the current persisted <see cref="DeploymentRecord"/> for
|
||||
/// the given deployment ID from the configuration database. This is a pure
|
||||
/// local DB read — it does not contact the site. The query-the-site-before-
|
||||
/// redeploy reconciliation (design: "Deployment Identity & Idempotency")
|
||||
/// lives in <see cref="TryReconcileWithSiteAsync"/>, which
|
||||
/// <see cref="DeployInstanceAsync"/> invokes on the deploy path.
|
||||
/// </summary>
|
||||
/// <param name="deploymentId">The unique deployment identifier to look up.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
public async Task<DeploymentRecord?> GetDeploymentStatusAsync(
|
||||
string deploymentId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _repository.GetDeploymentByDeploymentIdAsync(deploymentId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeploymentManager-006: query-the-site-before-redeploy reconciliation.
|
||||
///
|
||||
/// The site query is issued ONLY when a prior <see cref="DeploymentRecord"/>
|
||||
/// for this instance is stuck <see cref="DeploymentStatus.InProgress"/>, or
|
||||
/// is <see cref="DeploymentStatus.Failed"/> due to a timeout — the only
|
||||
/// cases where the site may have applied the config without central
|
||||
/// learning of it. Fresh first-time deploys and redeploys after a clean
|
||||
/// prior <see cref="DeploymentStatus.Success"/> skip the extra round-trip.
|
||||
///
|
||||
/// Reconciliation: if the site already has the TARGET revision hash, the
|
||||
/// prior record is marked <see cref="DeploymentStatus.Success"/> (with its
|
||||
/// <see cref="DeploymentRecord.RevisionHash"/> corrected to the target —
|
||||
/// DeploymentManager-016) and returned (the caller must NOT re-send the
|
||||
/// deploy). The same post-success side effects as the normal deploy path
|
||||
/// are applied — instance <see cref="InstanceState.Enabled"/> and a stored
|
||||
/// <see cref="DeployedConfigSnapshot"/> (DeploymentManager-015) — so central
|
||||
/// and site state do not diverge. Otherwise <c>null</c> is returned and the
|
||||
/// normal deploy proceeds.
|
||||
///
|
||||
/// Query failure: if the site is unreachable or the query times out, this
|
||||
/// returns <c>null</c> (fall through to a normal deploy) — site-side
|
||||
/// stale-rejection of an older revision hash is the safety net. The deploy
|
||||
/// is never aborted on a failed query.
|
||||
/// </summary>
|
||||
private async Task<DeploymentRecord?> TryReconcileWithSiteAsync(
|
||||
Instance instance,
|
||||
string targetRevisionHash,
|
||||
string configJson,
|
||||
string currentUser,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var prior = await _repository.GetCurrentDeploymentStatusAsync(instance.Id, cancellationToken);
|
||||
if (prior == null || !ShouldQuerySiteBeforeRedeploy(prior))
|
||||
return null;
|
||||
|
||||
DeploymentStateQueryResponse response;
|
||||
try
|
||||
{
|
||||
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
|
||||
var query = new DeploymentStateQueryRequest(
|
||||
Guid.NewGuid().ToString("N"), instance.UniqueName, DateTimeOffset.UtcNow);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Querying site {SiteId} for applied deployment state of instance {Instance} " +
|
||||
"before re-deploy (prior record {DeploymentId} is {Status})",
|
||||
siteId, instance.UniqueName, prior.DeploymentId, prior.Status);
|
||||
|
||||
response = await _communicationService.QueryDeploymentStateAsync(
|
||||
siteId, query, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Query failure (site unreachable / timeout): do NOT abort. Fall
|
||||
// through to a normal deploy; site-side stale-rejection of an older
|
||||
// revision hash is the safety net.
|
||||
_logger.LogWarning(ex,
|
||||
"Site query before re-deploy of instance {Instance} failed; " +
|
||||
"proceeding with normal deploy (site-side stale-rejection is the safety net)",
|
||||
instance.UniqueName);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.IsDeployed &&
|
||||
string.Equals(response.AppliedRevisionHash, targetRevisionHash, StringComparison.Ordinal))
|
||||
{
|
||||
// The site already has the target revision — the prior deployment
|
||||
// actually succeeded. Reconcile the stale record instead of
|
||||
// re-sending the deploy.
|
||||
_logger.LogInformation(
|
||||
"Site already has target revision {RevisionHash} for instance {Instance}; " +
|
||||
"marking prior deployment record {DeploymentId} Success without re-deploying",
|
||||
targetRevisionHash, instance.UniqueName, prior.DeploymentId);
|
||||
|
||||
prior.Status = DeploymentStatus.Success;
|
||||
prior.ErrorMessage = null;
|
||||
prior.CompletedAt = DateTimeOffset.UtcNow;
|
||||
// DeploymentManager-016: the prior record can legitimately carry a
|
||||
// different (stale) revision hash than the current target. The site
|
||||
// confirmed it is running the target revision, so the persisted
|
||||
// record, the audit entry below, and the site must all agree.
|
||||
prior.RevisionHash = targetRevisionHash;
|
||||
await _repository.UpdateDeploymentRecordAsync(prior, cancellationToken);
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
NotifyStatusChange(prior);
|
||||
|
||||
// DeploymentManager-015: a reconciled deployment must perform the
|
||||
// SAME post-success side effects as the normal deploy path — set
|
||||
// the instance State to Enabled and store/refresh the deployed
|
||||
// config snapshot — otherwise the central state machine and the
|
||||
// deployed-snapshot invariant diverge from what the site is running.
|
||||
//
|
||||
// DeploymentManager-018: the reconciliation path runs only when the
|
||||
// prior record is InProgress or timeout-Failed — exactly the cases
|
||||
// that survive a central failover. The in-memory operation lock is
|
||||
// lost on failover, so an operator may have legitimately invoked
|
||||
// Disable on the instance between the original timed-out deploy and
|
||||
// this redeploy. Disable does not change the deployed config, so the
|
||||
// site still reports the target revision hash. Reconciliation must
|
||||
// therefore PRESERVE an intentional Disabled state instead of
|
||||
// silently flipping it back to Enabled — pass forceEnabledState:
|
||||
// false so the helper only promotes NotDeployed → Enabled (the
|
||||
// first-deploy-timed-out case) and leaves an explicit Disabled
|
||||
// alone.
|
||||
await ApplyPostSuccessSideEffectsAsync(
|
||||
instance, prior.DeploymentId, targetRevisionHash, configJson,
|
||||
forceEnabledState: false, cancellationToken);
|
||||
|
||||
// DeploymentManager-020: attribute the audit row to the user driving
|
||||
// THIS redeploy (the caller of DeployInstanceAsync), not the user
|
||||
// who issued the original timed-out / stuck deployment. The original
|
||||
// deployer is preserved in the detail object so forensics can still
|
||||
// see who launched the run that reconciliation rescued.
|
||||
await _auditService.LogAsync(currentUser, "DeployReconciled", "Instance",
|
||||
instance.Id.ToString(), instance.UniqueName,
|
||||
new
|
||||
{
|
||||
DeploymentId = prior.DeploymentId,
|
||||
RevisionHash = targetRevisionHash,
|
||||
OriginalDeployer = prior.DeployedBy
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return prior;
|
||||
}
|
||||
|
||||
// Site does not have the target revision (or is not deployed) — proceed
|
||||
// with the normal deploy.
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeploymentManager-006: the site is queried before a re-deploy only when a
|
||||
/// prior record is stuck <see cref="DeploymentStatus.InProgress"/>, or is
|
||||
/// <see cref="DeploymentStatus.Failed"/> because the site command timed out
|
||||
/// (detected via the <see cref="TimeoutFailurePrefix"/> error-message
|
||||
/// marker). All other prior states skip the query.
|
||||
/// </summary>
|
||||
private static bool ShouldQuerySiteBeforeRedeploy(DeploymentRecord prior) =>
|
||||
prior.Status == DeploymentStatus.InProgress
|
||||
|| (prior.Status == DeploymentStatus.Failed
|
||||
&& prior.ErrorMessage != null
|
||||
&& prior.ErrorMessage.StartsWith(TimeoutFailurePrefix, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Post-success side effects shared by the normal deploy path and the
|
||||
/// DeploymentManager-006 reconciliation path: set the instance
|
||||
/// <see cref="InstanceState.Enabled"/> (WP-4) and store/refresh the
|
||||
/// deployed config snapshot (WP-8). Factored into one helper so the two
|
||||
/// paths cannot drift (DeploymentManager-015).
|
||||
///
|
||||
/// DeploymentManager-018: <paramref name="forceEnabledState"/> distinguishes
|
||||
/// the two callers. The normal deploy path passes <c>true</c> — a fresh
|
||||
/// successful apply legitimately puts the instance into <see cref="InstanceState.Enabled"/>
|
||||
/// (the documented "Deploy on a Disabled instance also enables it" semantics
|
||||
/// of <see cref="StateTransitionValidator"/>). The reconciliation path
|
||||
/// passes <c>false</c>: it is reconciling a *prior* deployment that may
|
||||
/// have completed before the current operator session (central failover
|
||||
/// loses the in-memory operation lock, so an operator may have legitimately
|
||||
/// Disabled the instance in between). On that path we only promote
|
||||
/// <see cref="InstanceState.NotDeployed"/> → <see cref="InstanceState.Enabled"/>
|
||||
/// (the first-deploy-timed-out case) and leave an explicit Disabled alone,
|
||||
/// so reconciliation never silently undoes a Disable.
|
||||
///
|
||||
/// Best-effort: the deployment record's terminal <see cref="DeploymentStatus.Success"/>
|
||||
/// status is already committed by the caller before this runs. A failure
|
||||
/// here is logged loudly for operator reconciliation but is NOT propagated —
|
||||
/// it must not flip the already-committed Success record back to Failed.
|
||||
/// </summary>
|
||||
private async Task ApplyPostSuccessSideEffectsAsync(
|
||||
Instance instance,
|
||||
string deploymentId,
|
||||
string revisionHash,
|
||||
string configJson,
|
||||
bool forceEnabledState,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// WP-4: Update instance state to Enabled on successful deployment.
|
||||
// DeploymentManager-018: on the reconciliation path
|
||||
// (forceEnabledState=false) only promote NotDeployed → Enabled,
|
||||
// preserving an intentional Disabled state set between the original
|
||||
// timed-out deploy and the redeploy.
|
||||
if (forceEnabledState || instance.State == InstanceState.NotDeployed)
|
||||
{
|
||||
instance.State = InstanceState.Enabled;
|
||||
}
|
||||
await _repository.UpdateInstanceAsync(instance, cancellationToken);
|
||||
|
||||
// WP-8: Store deployed config snapshot
|
||||
await StoreDeployedSnapshotAsync(
|
||||
instance.Id, deploymentId, revisionHash, configJson, cancellationToken);
|
||||
|
||||
await _repository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception postEx)
|
||||
{
|
||||
_logger.LogError(postEx,
|
||||
"Deployment {DeploymentId} for instance {Instance} was applied by the site and " +
|
||||
"recorded Success, but post-success persistence (instance state / config snapshot) " +
|
||||
"failed -- central and site state may diverge until reconciled",
|
||||
deploymentId, instance.UniqueName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeploymentManager-019: write a "<Action>TimedOut" audit entry on
|
||||
/// behalf of a lifecycle command (Disable / Enable / Delete) whose site
|
||||
/// round-trip exceeded <see cref="DeploymentManagerOptions.LifecycleCommandTimeout"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Mirrors the <c>DeployFailed</c> pattern in
|
||||
/// <see cref="DeployInstanceAsync"/>: the audit write uses
|
||||
/// <see cref="CancellationToken.None"/> so the operator's outer cancellation
|
||||
/// (the usual reason this path runs) cannot also prevent the audit row from
|
||||
/// being persisted. The detail object carries the lifecycle command id, the
|
||||
/// timeout that fired, and the original exception message so an operator can
|
||||
/// correlate the audit entry with the UI-surfaced timeout error.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Wrapped in try/catch — a failed audit write must NOT mask the underlying
|
||||
/// timeout from the caller; it is logged at Warning so the operator can
|
||||
/// reconcile but never thrown.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="user">The username who initiated the lifecycle command.</param>
|
||||
/// <param name="action">The audit action name (<c>DisableTimedOut</c>, <c>EnableTimedOut</c>, or <c>DeleteTimedOut</c>).</param>
|
||||
/// <param name="instanceId">The numeric instance id, recorded on the audit row.</param>
|
||||
/// <param name="instanceUniqueName">The instance unique name used as the audit target name.</param>
|
||||
/// <param name="commandId">The lifecycle command's correlation id, so the audit entry can be matched to logs.</param>
|
||||
/// <param name="timeoutException">The captured <see cref="TimeoutException"/> or <see cref="OperationCanceledException"/>.</param>
|
||||
private async Task TryLogLifecycleTimeoutAsync(
|
||||
string user,
|
||||
string action,
|
||||
int instanceId,
|
||||
string instanceUniqueName,
|
||||
string commandId,
|
||||
Exception timeoutException)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _auditService.LogAsync(
|
||||
user,
|
||||
action,
|
||||
"Instance",
|
||||
instanceId.ToString(),
|
||||
instanceUniqueName,
|
||||
new
|
||||
{
|
||||
CommandId = commandId,
|
||||
Deadline = _options.LifecycleCommandTimeout,
|
||||
Error = timeoutException.Message,
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception auditEx)
|
||||
{
|
||||
// A failed audit write must not bury the timeout for the caller —
|
||||
// just log so an operator can investigate the audit-pipeline issue.
|
||||
_logger.LogWarning(auditEx,
|
||||
"Failed to write {Action} audit entry for instance {Instance} (commandId={CommandId})",
|
||||
action, instanceUniqueName, commandId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StoreDeployedSnapshotAsync(
|
||||
int instanceId,
|
||||
string deploymentId,
|
||||
string revisionHash,
|
||||
string configJson,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _repository.GetDeployedSnapshotByInstanceIdAsync(instanceId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.DeploymentId = deploymentId;
|
||||
existing.RevisionHash = revisionHash;
|
||||
existing.ConfigurationJson = configJson;
|
||||
existing.DeployedAt = DateTimeOffset.UtcNow;
|
||||
await _repository.UpdateDeployedSnapshotAsync(existing, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var snapshot = new DeployedConfigSnapshot(deploymentId, revisionHash, configJson)
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
DeployedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
await _repository.AddDeployedSnapshotAsync(snapshot, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Result of comparing deployed vs template-derived configuration.
|
||||
/// </summary>
|
||||
/// <param name="Diff">
|
||||
/// DeploymentManager-007: structured added/removed/changed detail for
|
||||
/// attributes, alarms, and scripts. Null only when the deployed snapshot could
|
||||
/// not be deserialized (corrupt / older schema), in which case
|
||||
/// <see cref="IsStale"/> still reflects the hash comparison.
|
||||
/// </param>
|
||||
public record DeploymentComparisonResult(
|
||||
int InstanceId,
|
||||
string DeployedRevisionHash,
|
||||
string CurrentRevisionHash,
|
||||
bool IsStale,
|
||||
DateTimeOffset DeployedAt,
|
||||
ConfigurationDiff? Diff = null);
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDeploymentStatusNotifier"/> implementation. A simple
|
||||
/// in-process event broadcaster: registered as a DI singleton so it is shared
|
||||
/// between the central-process <see cref="DeploymentService"/> and the Central
|
||||
/// UI's Blazor circuits (CentralUI-006).
|
||||
///
|
||||
/// A throwing subscriber must never break the deployment pipeline, so each
|
||||
/// handler is invoked individually and its exceptions are caught and logged.
|
||||
/// </summary>
|
||||
public sealed class DeploymentStatusNotifier : IDeploymentStatusNotifier
|
||||
{
|
||||
private readonly ILogger<DeploymentStatusNotifier> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of <see cref="DeploymentStatusNotifier"/>.</summary>
|
||||
/// <param name="logger">Logger instance used when a subscriber throws.</param>
|
||||
public DeploymentStatusNotifier(ILogger<DeploymentStatusNotifier> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event Action<DeploymentStatusChange>? StatusChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NotifyStatusChanged(DeploymentStatusChange change)
|
||||
{
|
||||
var handlers = StatusChanged;
|
||||
if (handlers == null)
|
||||
return;
|
||||
|
||||
// Invoke each subscriber in isolation: one faulting handler (e.g. a
|
||||
// disposed Blazor circuit) must not stop the others from being notified
|
||||
// and must not propagate back into the deployment pipeline.
|
||||
foreach (var handler in handlers.GetInvocationList())
|
||||
{
|
||||
try
|
||||
{
|
||||
((Action<DeploymentStatusChange>)handler)(change);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"A deployment-status-change subscriber threw for deployment {DeploymentId} " +
|
||||
"(status {Status}); continuing with remaining subscribers",
|
||||
change.DeploymentId, change.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the TemplateEngine services (FlatteningService, ValidationService, RevisionHashService)
|
||||
/// into a single pipeline for deployment use.
|
||||
///
|
||||
/// WP-16: This captures template state at the time of flatten, ensuring that concurrent template edits
|
||||
/// (last-write-wins) do not conflict with in-progress deployments.
|
||||
/// </summary>
|
||||
public class FlatteningPipeline : IFlatteningPipeline
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templateRepo;
|
||||
private readonly ISiteRepository _siteRepo;
|
||||
private readonly FlatteningService _flatteningService;
|
||||
private readonly ValidationService _validationService;
|
||||
private readonly RevisionHashService _revisionHashService;
|
||||
|
||||
/// <summary>Initializes a new <see cref="FlatteningPipeline"/> with the required template engine and site repositories and services.</summary>
|
||||
/// <param name="templateRepo">Repository for loading templates and instance data.</param>
|
||||
/// <param name="siteRepo">Repository for loading site data used during validation.</param>
|
||||
/// <param name="flatteningService">Service that flattens the template inheritance chain into a resolved config.</param>
|
||||
/// <param name="validationService">Service that performs semantic validation on the flattened config.</param>
|
||||
/// <param name="revisionHashService">Service that computes the revision hash for staleness detection.</param>
|
||||
public FlatteningPipeline(
|
||||
ITemplateEngineRepository templateRepo,
|
||||
ISiteRepository siteRepo,
|
||||
FlatteningService flatteningService,
|
||||
ValidationService validationService,
|
||||
RevisionHashService revisionHashService)
|
||||
{
|
||||
_templateRepo = templateRepo;
|
||||
_siteRepo = siteRepo;
|
||||
_flatteningService = flatteningService;
|
||||
_validationService = validationService;
|
||||
_revisionHashService = revisionHashService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||
int instanceId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Load instance with full graph
|
||||
var instance = await _templateRepo.GetInstanceByIdAsync(instanceId, cancellationToken);
|
||||
if (instance == null)
|
||||
return Result<FlatteningPipelineResult>.Failure($"Instance with ID {instanceId} not found.");
|
||||
|
||||
// Build template chain
|
||||
var templateChain = await BuildTemplateChainAsync(instance.TemplateId, cancellationToken);
|
||||
if (templateChain.Count == 0)
|
||||
return Result<FlatteningPipelineResult>.Failure("Template chain is empty.");
|
||||
|
||||
// Build composition maps, walking nested compositions so the flattener
|
||||
// can resolve composed-of-composed attributes / alarms / scripts (e.g.
|
||||
// a parent that composes Pump where Pump itself composes AlarmSensor
|
||||
// produces "Pump.AlarmSensor.SensorReading").
|
||||
var compositionMap = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.TemplateComposition>>();
|
||||
var composedChains = new Dictionary<int, IReadOnlyList<Commons.Entities.Templates.Template>>();
|
||||
var processedTemplateIds = new HashSet<int>();
|
||||
var pendingChains = new Queue<IReadOnlyList<Commons.Entities.Templates.Template>>();
|
||||
|
||||
pendingChains.Enqueue(templateChain);
|
||||
while (pendingChains.Count > 0)
|
||||
{
|
||||
var chain = pendingChains.Dequeue();
|
||||
foreach (var template in chain)
|
||||
{
|
||||
if (!processedTemplateIds.Add(template.Id)) continue;
|
||||
|
||||
var compositions = await _templateRepo.GetCompositionsByTemplateIdAsync(template.Id, cancellationToken);
|
||||
if (compositions.Count == 0) continue;
|
||||
|
||||
compositionMap[template.Id] = compositions;
|
||||
foreach (var comp in compositions)
|
||||
{
|
||||
if (composedChains.ContainsKey(comp.ComposedTemplateId)) continue;
|
||||
|
||||
var composedChain = await BuildTemplateChainAsync(comp.ComposedTemplateId, cancellationToken);
|
||||
composedChains[comp.ComposedTemplateId] = composedChain;
|
||||
pendingChains.Enqueue(composedChain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load data connections for the site
|
||||
var dataConnections = await LoadDataConnectionsAsync(instance.SiteId, cancellationToken);
|
||||
|
||||
// Flatten
|
||||
var flattenResult = _flatteningService.Flatten(
|
||||
instance, templateChain, compositionMap, composedChains, dataConnections);
|
||||
|
||||
if (flattenResult.IsFailure)
|
||||
return Result<FlatteningPipelineResult>.Failure(flattenResult.Error);
|
||||
|
||||
var config = flattenResult.Value;
|
||||
|
||||
// Load shared scripts for semantic validation
|
||||
var sharedScriptEntities = await _templateRepo.GetAllSharedScriptsAsync(cancellationToken);
|
||||
var resolvedSharedScripts = sharedScriptEntities.Select(s => new ResolvedScript
|
||||
{
|
||||
CanonicalName = s.Name,
|
||||
Code = s.Code,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition
|
||||
}).ToList();
|
||||
|
||||
// Validate
|
||||
var validation = _validationService.Validate(config, resolvedSharedScripts);
|
||||
|
||||
// Compute revision hash
|
||||
var hash = _revisionHashService.ComputeHash(config);
|
||||
|
||||
return Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(config, hash, validation));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<Commons.Entities.Templates.Template>> BuildTemplateChainAsync(
|
||||
int templateId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chain = new List<Commons.Entities.Templates.Template>();
|
||||
var currentId = (int?)templateId;
|
||||
|
||||
while (currentId.HasValue)
|
||||
{
|
||||
var template = await _templateRepo.GetTemplateWithChildrenAsync(currentId.Value, cancellationToken);
|
||||
if (template == null) break;
|
||||
chain.Add(template);
|
||||
currentId = template.ParentTemplateId;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyDictionary<int, DataConnection>> LoadDataConnectionsAsync(
|
||||
int siteId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, cancellationToken);
|
||||
return connections.ToDictionary(c => c.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Payload describing a single deployment-record status change. Kept small —
|
||||
/// just the deployment identity, the owning instance, and the new status — so
|
||||
/// it is cheap to raise on the hot path and cheap for subscribers to handle.
|
||||
/// </summary>
|
||||
/// <param name="DeploymentId">The unique deployment ID whose status changed.</param>
|
||||
/// <param name="InstanceId">The instance the deployment record belongs to.</param>
|
||||
/// <param name="Status">The status the deployment record was just written with.</param>
|
||||
public readonly record struct DeploymentStatusChange(
|
||||
string DeploymentId,
|
||||
int InstanceId,
|
||||
DeploymentStatus Status);
|
||||
|
||||
/// <summary>
|
||||
/// CentralUI-006: push-based deployment-status change notification.
|
||||
///
|
||||
/// The design (Component-CentralUI "Real-Time Updates") requires deployment
|
||||
/// status transitions to push to the UI immediately via SignalR, with no
|
||||
/// polling. <see cref="DeploymentService"/> raises <see cref="StatusChanged"/>
|
||||
/// whenever it writes a <see cref="Commons.Entities.Deployment.DeploymentRecord"/>
|
||||
/// status; the Central UI's deployment-status page subscribes to it and
|
||||
/// re-renders over its existing Blazor Server SignalR circuit.
|
||||
///
|
||||
/// Registered as a DI singleton (see <see cref="ServiceCollectionExtensions.AddDeploymentManager"/>)
|
||||
/// so the scoped <see cref="DeploymentService"/> and the Blazor circuit's
|
||||
/// scoped page component share the same instance — both run in the same
|
||||
/// central Host process.
|
||||
/// </summary>
|
||||
public interface IDeploymentStatusNotifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised after a deployment record's status has been written. Handlers run
|
||||
/// synchronously on the caller's thread; subscribers must not block and
|
||||
/// should marshal any UI work onto their own dispatcher.
|
||||
/// </summary>
|
||||
event Action<DeploymentStatusChange>? StatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Raises <see cref="StatusChanged"/>. Called by <see cref="DeploymentService"/>
|
||||
/// at every point a deployment record's status is persisted. A throwing
|
||||
/// subscriber must not break the deployment pipeline, so handler exceptions
|
||||
/// are swallowed by the implementation.
|
||||
/// </summary>
|
||||
/// <param name="change">The deployment status change to broadcast to subscribers.</param>
|
||||
void NotifyStatusChanged(DeploymentStatusChange change);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the TemplateEngine flattening + validation + hashing pipeline.
|
||||
/// Used by DeploymentService to obtain a validated, hashed FlattenedConfiguration.
|
||||
/// </summary>
|
||||
public interface IFlatteningPipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Flattens and validates an instance configuration, returning the configuration,
|
||||
/// revision hash, and validation result.
|
||||
/// </summary>
|
||||
/// <param name="instanceId">Id of the instance to flatten and validate.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
Task<Result<FlatteningPipelineResult>> FlattenAndValidateAsync(
|
||||
int instanceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the flattening pipeline: configuration, hash, and validation.
|
||||
/// </summary>
|
||||
public record FlatteningPipelineResult(
|
||||
FlattenedConfiguration Configuration,
|
||||
string RevisionHash,
|
||||
ValidationResult Validation);
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// WP-3: Per-instance operation lock. Only one mutating operation (deploy, disable, enable, delete)
|
||||
/// may be in progress per instance at a time. Different instances can proceed in parallel.
|
||||
///
|
||||
/// Implementation: ConcurrentDictionary of ref-counted SemaphoreSlim(1,1) keyed by instance
|
||||
/// unique name. The lock is released on completion, timeout, or failure.
|
||||
/// Lost on central failover (acceptable per design -- in-progress treated as failed).
|
||||
///
|
||||
/// DeploymentManager-005: each entry is ref-counted. The semaphore is created on the
|
||||
/// first acquire/wait, shared while there are waiters or a holder, and removed +
|
||||
/// <see cref="IDisposable.Dispose"/>d when the last reference is released — so the dictionary
|
||||
/// does not accumulate one kernel wait handle per distinct instance name forever.
|
||||
/// </summary>
|
||||
public class OperationLockManager
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly Dictionary<string, LockEntry> _locks = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Number of lock entries currently tracked. Used for diagnostics and to
|
||||
/// verify that semaphores are reclaimed (DeploymentManager-005).
|
||||
/// </summary>
|
||||
public int TrackedLockCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _locks.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acquires the operation lock for the given instance. Returns a disposable that releases the lock.
|
||||
/// Throws TimeoutException if the lock cannot be acquired within the timeout.
|
||||
/// </summary>
|
||||
/// <param name="instanceUniqueName">The unique name of the instance to lock.</param>
|
||||
/// <param name="timeout">Maximum time to wait for the lock before throwing <see cref="TimeoutException"/>.</param>
|
||||
/// <param name="cancellationToken">Cancellation token to abort the wait.</param>
|
||||
/// <returns>An <see cref="IDisposable"/> that releases the lock when disposed.</returns>
|
||||
public async Task<IDisposable> AcquireAsync(string instanceUniqueName, TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Reserve a reference (creating the entry if needed) BEFORE waiting, so a
|
||||
// concurrent waiter for the same instance shares the same semaphore and
|
||||
// the entry survives until every waiter/holder has released it.
|
||||
LockEntry entry;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_locks.TryGetValue(instanceUniqueName, out entry!))
|
||||
{
|
||||
entry = new LockEntry();
|
||||
_locks[instanceUniqueName] = entry;
|
||||
}
|
||||
entry.RefCount++;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await entry.Semaphore.WaitAsync(timeout, cancellationToken))
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Could not acquire operation lock for instance '{instanceUniqueName}' within {timeout.TotalSeconds}s. " +
|
||||
"Another mutating operation is in progress.");
|
||||
}
|
||||
}
|
||||
catch (Exception) when (DropReferenceOnFailure(instanceUniqueName, entry))
|
||||
{
|
||||
// DropReferenceOnFailure always returns false; the filter just runs
|
||||
// the cleanup so the reservation is not leaked when WaitAsync throws
|
||||
// or times out (TimeoutException / OperationCanceledException). The
|
||||
// exception still propagates. The semaphore was NOT entered on any
|
||||
// of these paths, so only the reference is dropped.
|
||||
throw;
|
||||
}
|
||||
|
||||
return new LockRelease(this, instanceUniqueName, entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a lock is currently held for the given instance (for diagnostics).
|
||||
/// </summary>
|
||||
/// <param name="instanceUniqueName">The unique name of the instance to check.</param>
|
||||
/// <returns><c>true</c> if a lock is currently held; otherwise <c>false</c>.</returns>
|
||||
public bool IsLocked(string instanceUniqueName)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _locks.TryGetValue(instanceUniqueName, out var entry) && entry.Semaphore.CurrentCount == 0;
|
||||
}
|
||||
}
|
||||
|
||||
private bool DropReferenceOnFailure(string instanceUniqueName, LockEntry entry)
|
||||
{
|
||||
ReleaseReference(instanceUniqueName, entry, semaphoreWasEntered: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops one reference to the entry. When <paramref name="semaphoreWasEntered"/>
|
||||
/// is true the semaphore is released first. When the reference count reaches
|
||||
/// zero the entry is removed from the dictionary and the semaphore disposed.
|
||||
/// </summary>
|
||||
private void ReleaseReference(string instanceUniqueName, LockEntry entry, bool semaphoreWasEntered)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
// Release the semaphore (handing the lock to any waiter) and drop the
|
||||
// reference under the same gate, so the dispose decision below cannot
|
||||
// race with the Release on an entry that another caller is reclaiming.
|
||||
if (semaphoreWasEntered)
|
||||
{
|
||||
entry.Semaphore.Release();
|
||||
}
|
||||
|
||||
entry.RefCount--;
|
||||
if (entry.RefCount <= 0 &&
|
||||
_locks.TryGetValue(instanceUniqueName, out var current) &&
|
||||
ReferenceEquals(current, entry))
|
||||
{
|
||||
_locks.Remove(instanceUniqueName);
|
||||
entry.Semaphore.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LockEntry
|
||||
{
|
||||
public readonly SemaphoreSlim Semaphore = new(1, 1);
|
||||
|
||||
/// <summary>Number of in-flight acquires (waiters + the current holder). Guarded by <see cref="_gate"/>.</summary>
|
||||
public int RefCount;
|
||||
}
|
||||
|
||||
private sealed class LockRelease : IDisposable
|
||||
{
|
||||
private readonly OperationLockManager _owner;
|
||||
private readonly string _instanceUniqueName;
|
||||
private readonly LockEntry _entry;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes the release handle with its owner, instance name, and lock entry.</summary>
|
||||
/// <param name="owner">The owning <see cref="OperationLockManager"/>.</param>
|
||||
/// <param name="instanceUniqueName">The instance unique name whose lock is held.</param>
|
||||
/// <param name="entry">The ref-counted semaphore entry to release on dispose.</param>
|
||||
public LockRelease(OperationLockManager owner, string instanceUniqueName, LockEntry entry)
|
||||
{
|
||||
_owner = owner;
|
||||
_instanceUniqueName = instanceUniqueName;
|
||||
_entry = entry;
|
||||
}
|
||||
|
||||
/// <summary>Releases the operation lock idempotently.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _disposed, 1, 0) == 0)
|
||||
{
|
||||
_owner.ReleaseReference(_instanceUniqueName, _entry, semaphoreWasEntered: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section that <see cref="DeploymentManagerOptions"/> is bound to.
|
||||
/// The Host binds this section to <c>appsettings.json</c> (see
|
||||
/// <c>Program.cs</c>); component libraries do not depend on
|
||||
/// <c>IConfiguration</c> directly, consistent with the Options-pattern
|
||||
/// convention enforced by <c>OptionsTests</c>.
|
||||
/// </summary>
|
||||
public const string OptionsSection = "ScadaBridge:DeploymentManager";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Deployment Manager services. <see cref="DeploymentManagerOptions"/>
|
||||
/// is registered via <see cref="OptionsServiceCollectionExtensions.AddOptions"/> so
|
||||
/// <c>IOptions<DeploymentManagerOptions></c> is always resolvable; the Host
|
||||
/// binds <see cref="OptionsSection"/> to configuration so the operation-lock and
|
||||
/// artifact-deployment timeouts are tunable via <c>appsettings.json</c>.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddDeploymentManager(this IServiceCollection services)
|
||||
{
|
||||
// DeploymentManager-008: ensure the options class is always resolvable.
|
||||
// The Host binds OptionsSection to appsettings.json; absent that binding
|
||||
// the declared option-class defaults apply.
|
||||
services.AddOptions<DeploymentManagerOptions>();
|
||||
services.AddSingleton<OperationLockManager>();
|
||||
|
||||
// CentralUI-006: push-based deployment-status notification. Registered
|
||||
// as a singleton so the scoped DeploymentService and the Central UI's
|
||||
// scoped Blazor page component share one instance — both run in the
|
||||
// same central Host process. The deployment-status page subscribes to
|
||||
// it instead of polling the database every 10 seconds.
|
||||
services.AddSingleton<IDeploymentStatusNotifier, DeploymentStatusNotifier>();
|
||||
|
||||
services.AddScoped<IFlatteningPipeline, FlatteningPipeline>();
|
||||
services.AddScoped<DeploymentService>();
|
||||
services.AddScoped<ArtifactDeploymentService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Deployment Manager Akka actor bindings. Actor creation is handled by the Host during actor system startup.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register into.</param>
|
||||
public static IServiceCollection AddDeploymentManagerActors(this IServiceCollection services)
|
||||
{
|
||||
// Akka actor registration is handled by Host component during actor system startup
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: State transition matrix for instance lifecycle.
|
||||
///
|
||||
/// State | Deploy | Disable | Enable | Delete
|
||||
/// ----------|--------|---------|--------|-------
|
||||
/// NotDeploy | OK | NO | NO | OK
|
||||
/// Enabled | OK | OK | NO | OK
|
||||
/// Disabled | OK* | NO | OK | OK
|
||||
///
|
||||
/// * Deploy on a Disabled instance also enables it.
|
||||
/// Delete removes the instance record entirely; it is valid from any state.
|
||||
/// </summary>
|
||||
public static class StateTransitionValidator
|
||||
{
|
||||
/// <summary>Returns true when a deploy operation is allowed from the given state.</summary>
|
||||
/// <param name="currentState">The current instance state.</param>
|
||||
public static bool CanDeploy(InstanceState currentState) =>
|
||||
currentState is InstanceState.NotDeployed or InstanceState.Enabled or InstanceState.Disabled;
|
||||
|
||||
/// <summary>Returns true when a disable operation is allowed from the given state.</summary>
|
||||
/// <param name="currentState">The current instance state.</param>
|
||||
public static bool CanDisable(InstanceState currentState) =>
|
||||
currentState == InstanceState.Enabled;
|
||||
|
||||
/// <summary>Returns true when an enable operation is allowed from the given state.</summary>
|
||||
/// <param name="currentState">The current instance state.</param>
|
||||
public static bool CanEnable(InstanceState currentState) =>
|
||||
currentState == InstanceState.Disabled;
|
||||
|
||||
/// <summary>Returns true when a delete operation is allowed from the given state.</summary>
|
||||
/// <param name="currentState">The current instance state.</param>
|
||||
public static bool CanDelete(InstanceState currentState) =>
|
||||
currentState is InstanceState.NotDeployed or InstanceState.Enabled or InstanceState.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a human-readable error message if the transition is invalid, or null if valid.
|
||||
/// </summary>
|
||||
/// <param name="currentState">The current instance state.</param>
|
||||
/// <param name="operation">The operation name to validate (e.g. "deploy", "disable", "enable", "delete").</param>
|
||||
public static string? ValidateTransition(InstanceState currentState, string operation)
|
||||
{
|
||||
var allowed = operation.ToLowerInvariant() switch
|
||||
{
|
||||
"deploy" => CanDeploy(currentState),
|
||||
"disable" => CanDisable(currentState),
|
||||
"enable" => CanEnable(currentState),
|
||||
"delete" => CanDelete(currentState),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (allowed) return null;
|
||||
|
||||
return $"Operation '{operation}' is not allowed when instance is in state '{currentState}'.";
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user