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.Observability;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
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;
///
/// Central-side deployment orchestration service.
/// Coordinates the full deployment pipeline:
/// 1. Validate instance state transition
/// 2. Acquire per-instance operation lock
/// 3. Flatten configuration via TemplateEngine (captures template state at time of flatten)
/// 4. Validate flattened configuration
/// 5. Compute revision hash and diff
/// 6. Stage a PendingDeployment row + send RefreshDeploymentCommand (notify-and-fetch; site fetches the config over HTTP)
/// 7. Track deployment status with optimistic concurrency
/// 8. Store deployed config snapshot
/// 9. Audit log all actions
///
/// Each deployment has a unique deployment ID (GUID) + revision hash.
/// Template state captured at flatten time -- last-write-wins on templates is safe.
///
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 RevisionHashService _revisionHashService;
private readonly IDeploymentStatusNotifier _statusNotifier;
private readonly DeploymentManagerOptions _options;
private readonly CommunicationOptions _commOptions;
private readonly ILogger _logger;
///
/// Prefix written to when a
/// deployment fails because the site command timed out or was cancelled.
/// Used by the query-before-redeploy trigger to tell
/// a timeout-induced failure apart from other deployment errors.
///
private const string TimeoutFailurePrefix = "Communication failure:";
///
/// Initializes a new instance of with all required dependencies.
///
/// Repository for deployment manager data access.
/// Repository for site data access.
/// Pipeline for flattening and validating template configurations.
/// Service for cross-cluster communication with sites.
/// Manager for per-instance operation locks.
/// Service for recording audit log entries.
/// Service for computing configuration diffs.
///
/// Service for recomputing a flattened configuration's revision hash. Used by
/// to derive the deployed-side
/// staleness hash from the (List-normalized) deserialized snapshot — see I-1.
///
/// Notifier for pushing deployment status changes to the UI.
/// Deployment manager configuration options.
///
/// Central-site communication options. The notify-and-fetch deploy path reads
/// (carried in the
/// RefreshDeploymentCommand so sites need no standing config) and
/// (the staged
/// PendingDeployment row's lifetime).
///
/// Logger instance.
public DeploymentService(
IDeploymentManagerRepository repository,
ISiteRepository siteRepository,
IFlatteningPipeline flatteningPipeline,
CommunicationService communicationService,
OperationLockManager lockManager,
IAuditService auditService,
DiffService diffService,
RevisionHashService revisionHashService,
IDeploymentStatusNotifier statusNotifier,
IOptions options,
IOptions communicationOptions,
ILogger logger)
{
_repository = repository;
_siteRepository = siteRepository;
_flatteningPipeline = flatteningPipeline;
_communicationService = communicationService;
_lockManager = lockManager;
_auditService = auditService;
_diffService = diffService;
_revisionHashService = revisionHashService;
_statusNotifier = statusNotifier;
_options = options.Value;
_commOptions = communicationOptions.Value;
_logger = logger;
}
///
/// 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 status is written.
///
private void NotifyStatusChange(DeploymentRecord record) =>
_statusNotifier.NotifyStatusChanged(
new DeploymentStatusChange(record.DeploymentId, record.InstanceId, record.Status));
///
/// 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.
///
/// When the 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
/// 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.
///
private async Task 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;
}
///
/// Deploy an instance to its site.
/// Generates unique deployment ID, computes revision hash.
/// Validates state transitions, uses optimistic concurrency.
/// Site-side apply is all-or-nothing (handled by DeploymentManagerActor).
/// Stores deployed config snapshot on success.
/// Captures template state at time of flatten.
///
/// The database ID of the instance to deploy.
/// The username initiating the deployment, recorded in the audit log.
/// Cancellation token for the operation.
/// A task that resolves to a success result containing the deployment record, or a failure result with an error message.
public async Task> DeployInstanceAsync(
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
// Load instance
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result.Failure($"Instance with ID {instanceId} not found.");
// Validate state transition
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "deploy");
if (transitionError != null)
return Result.Failure(transitionError);
// Acquire per-instance operation lock
using var lockHandle = await _lockManager.AcquireAsync(
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
// Generate unique deployment ID
var deploymentId = Guid.NewGuid().ToString("N");
// Flatten configuration (captures template state at this point in time)
var flattenResult = await _flatteningPipeline.FlattenAndValidateAsync(instanceId, cancellationToken);
if (flattenResult.IsFailure)
return Result.Failure($"Validation failed: {flattenResult.Error}");
var flattenedConfig = flattenResult.Value.Configuration;
var revisionHash = flattenResult.Value.RevisionHash;
var validationResult = flattenResult.Value.Validation;
if (!validationResult.IsValid)
{
// Return a grouped/summarized error (leading count + per-module
// rollup, capped) instead of a flat semicolon-joined dump that becomes a wall
// of text for instances with dozens of unbound attributes. The full per-entry
// list still goes to the deploy log for operators who need every clause.
_logger.LogWarning(
"Pre-deployment validation failed for instance {InstanceId} ({ErrorCount} error(s)): {Detail}",
instanceId,
validationResult.Errors.Count,
string.Join("; ", validationResult.Errors.Select(e => e.Message)));
return Result.Failure(
$"Pre-deployment validation failed: {validationResult.SummarizeErrors()}");
}
// Serialize for transmission (also the payload stored in the deployed
// snapshot on success / reconciliation).
var configJson = JsonSerializer.Serialize(flattenedConfig);
// 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.Success(reconciled);
// Notify-and-fetch: the site fetches the staged config from
// CentralFetchBaseUrl, so a deploy is impossible without it. Fail fast
// here — BEFORE creating an InProgress record or staging a pending row —
// so the operator sees a clear configuration error instead of a
// confusing downstream site-fetch failure (and no InProgress record is
// stranded).
if (string.IsNullOrEmpty(_commOptions.CentralFetchBaseUrl))
return Result.Failure(
"CentralFetchBaseUrl is not configured — required for deployment (notify-and-fetch).");
// Create the deployment record directly in InProgress.
//
// 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 (which 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
{
// Notify-and-fetch: instead of shipping the (potentially oversized,
// silently-dropped >128 KB) flattened config inline in a
// DeployInstanceCommand, stage it in a PendingDeployment row and send
// a small RefreshDeploymentCommand. The site fetches the config from
// CentralFetchBaseUrl over HTTP using the per-deployment fetch token.
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
var token = DeploymentFetchToken.Generate();
var stagedAt = DateTimeOffset.UtcNow;
await _repository.AddPendingDeploymentAsync(new PendingDeployment(
deploymentId, instanceId, revisionHash, configJson, token,
stagedAt, stagedAt + _commOptions.PendingDeploymentTtl), cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
var command = new RefreshDeploymentCommand(
deploymentId, instance.UniqueName, revisionHash, user, stagedAt,
_commOptions.CentralFetchBaseUrl, token);
_logger.LogInformation(
"Sending deployment {DeploymentId} for instance {Instance} to site {SiteId} (notify-and-fetch)",
deploymentId, instance.UniqueName, siteId);
// Cleanup of the staged PendingDeployment is TTL-based ONLY — the row
// is deliberately NOT deleted on success or in the catch. On a
// central-side Ask timeout the site may have applied AND told the
// standby node to fetch; deleting now would 404 that in-flight
// standby fetch and break failover. Supersession bounds pending rows
// to ≤1 per instance and the fetch endpoint enforces the TTL, so
// leaving rows for TTL purge is safe. Expired rows are swept by the
// central PendingDeploymentPurgeActor singleton on its
// CommunicationOptions.PendingDeploymentPurgeInterval cadence.
var response = await _communicationService.RefreshDeploymentAsync(siteId, command, cancellationToken);
// Update status based on site response.
record.Status = response.Status;
record.ErrorMessage = response.ErrorMessage;
record.CompletedAt = DateTimeOffset.UtcNow;
// 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)
{
// Telemetry: one instance deployment successfully applied to a
// site. Counted once per successful deploy operation (the unit
// of scadabridge.deployments.applied — one DeployInstanceAsync
// deploys exactly one instance to one site). Emitted only on this
// confirmed-Success path, so failures, timeouts/retries (the
// catch block), and the reconciliation path (which recovers a
// PRIOR timed-out apply rather than performing a fresh one) do
// not increment it.
ScadaBridgeTelemetry.RecordDeploymentApplied();
// 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. Best-effort and post-terminal: the deployment record's
// status is already committed above, so a failed audit write must NOT
// fall through to the outer catch and flip a committed Success back to
// Failed (audit is best-effort — the action's own result is authoritative).
await TryLogAuditAsync(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.Success(record)
: Result.Failure(
$"Deployment failed: {response.ErrorMessage ?? "Unknown error"}");
}
catch (Exception ex)
{
// 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.
//
// 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 or Akka.Actor.AskTimeoutException;
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);
// Note: if the staging SaveChangesAsync above was interrupted, an
// Added PendingDeployment may still be tracked and will be
// committed by this cleanup save. That row is orphaned (no
// RefreshDeploymentCommand was sent, so no site holds its token)
// and is removed by TTL purge / superseded by the next deploy --
// harmless.
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.Failure(
isTimeout
? $"Deployment timed out: {ex.Message}"
: $"Deployment failed: {ex.Message}");
}
}
///
/// Disable an instance. Stops Instance Actor, retains config, S&F drains.
///
/// The database ID of the instance to disable.
/// The username initiating the operation, recorded in the audit log.
/// Cancellation token for the operation.
/// A task that resolves to a success result with the site response, or a failure result with an error message.
public async Task> DisableInstanceAsync(
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result.Failure($"Instance with ID {instanceId} not found.");
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "disable");
if (transitionError != null)
return Result.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);
// 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)
{
// 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 "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.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 TryLogAuditAsync(user, "Disable", "Instance", instanceId.ToString(),
instance.UniqueName, new { CommandId = commandId, response.Success },
cancellationToken);
return response.Success
? Result.Success(response)
: Result.Failure(response.ErrorMessage ?? "Disable failed.");
}
///
/// Enable an instance. Re-creates Instance Actor from stored config.
///
/// The database ID of the instance to enable.
/// The username initiating the operation, recorded in the audit log.
/// Cancellation token for the operation.
/// A task that resolves to a success result with the site response, or a failure result with an error message.
public async Task> EnableInstanceAsync(
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result.Failure($"Instance with ID {instanceId} not found.");
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "enable");
if (transitionError != null)
return Result.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);
// 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)
{
// 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.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 TryLogAuditAsync(user, "Enable", "Instance", instanceId.ToString(),
instance.UniqueName, new { CommandId = commandId, response.Success },
cancellationToken);
return response.Success
? Result.Success(response)
: Result.Failure(response.ErrorMessage ?? "Enable failed.");
}
///
/// 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
/// CommunicationOptions.LifecycleTimeout (applied inside
/// ).
///
/// The database ID of the instance to delete.
/// The username initiating the deletion, recorded in the audit log.
/// Cancellation token for the operation.
/// A task that resolves to a success result with the site response, or a failure result with an error message.
public async Task> DeleteInstanceAsync(
int instanceId,
string user,
CancellationToken cancellationToken = default)
{
var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken);
if (instance == null)
return Result.Failure($"Instance with ID {instanceId} not found.");
var transitionError = StateTransitionValidator.ValidateTransition(instance.State, "delete");
if (transitionError != null)
return Result.Failure(transitionError);
using var lockHandle = await _lockManager.AcquireAsync(
instance.UniqueName, _options.OperationLockTimeout, cancellationToken);
var commandId = Guid.NewGuid().ToString("N");
// A NotDeployed instance has no live Instance Actor at any site, so Delete is
// a pure central-side record cleanup -- there is nothing to tear down at the
// site and no reason to require the site be reachable. Transport-imported
// instances always land NotDeployed (often against an uncommissioned or
// unreachable site), so a site round-trip here would make them undeletable
// until their site comes online. Skip the site entirely and remove the record.
if (instance.State == InstanceState.NotDeployed)
{
try
{
await _repository.DeleteInstanceAsync(instanceId, cancellationToken);
await _repository.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to delete NotDeployed instance {Instance} centrally", instance.UniqueName);
return Result.Failure(
$"Delete failed: the central record for '{instance.UniqueName}' could not be removed: {ex.Message}.");
}
await TryLogAuditAsync(user, "Delete", "Instance", instanceId.ToString(),
instance.UniqueName,
new { CommandId = commandId, Success = true, SiteRoundTripSkipped = true },
cancellationToken);
return Result.Success(new InstanceLifecycleResponse(
commandId, instance.UniqueName, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow));
}
var siteId = await ResolveSiteIdentifierAsync(instance.SiteId, cancellationToken);
var command = new DeleteInstanceCommand(commandId, instance.UniqueName, DateTimeOffset.UtcNow);
// 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)
{
// 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.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).
//
// 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 TryLogAuditAsync(user, "DeleteOrphaned", "Instance", instanceId.ToString(),
instance.UniqueName, new { CommandId = commandId, Error = ex.Message },
CancellationToken.None);
return Result.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 TryLogAuditAsync(user, "Delete", "Instance", instanceId.ToString(),
instance.UniqueName, new { CommandId = commandId, response.Success },
cancellationToken);
return response.Success
? Result.Success(response)
: Result.Failure(
response.ErrorMessage ?? "Delete failed. Site may be unreachable.");
}
///
/// 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 of
/// added/removed/changed attributes, alarms, and scripts (including data
/// connection binding changes) computed by the TemplateEngine
/// .
///
/// The database ID of the instance to compare.
/// Cancellation token for the operation.
/// A task that resolves to a success result with the comparison, or a failure result if no snapshot exists.
public async Task> GetDeploymentComparisonAsync(
int instanceId,
CancellationToken cancellationToken = default)
{
var snapshot = await _repository.GetDeployedSnapshotByInstanceIdAsync(instanceId, cancellationToken);
if (snapshot == null)
return Result.Failure("No deployed snapshot found for this instance.");
// Compute current template-derived config. This read-only comparison only needs
// the revision hash (staleness) + the flattened config (structured diff) — NOT a
// script compile — so skip the expensive Roslyn ValidateScriptCompilation stage.
// The deploy gate remains the authoritative compile.
var currentResult = await _flatteningPipeline.FlattenAndValidateAsync(
instanceId, cancellationToken, validateScripts: false);
if (currentResult.IsFailure)
return Result.Failure($"Cannot compute current config: {currentResult.Error}");
var currentConfig = currentResult.Value.Configuration;
var currentHash = currentResult.Value.RevisionHash;
// I-1 (latent): the snapshot's ConfigurationJson + RevisionHash froze the
// FLATTENED config at deploy time. The current config is a FRESH flatten,
// now always in native List form (consolidated element-type/coercion
// into AttributeValueCodec, which emits native-form JSON arrays). A List
// attribute deployed in the OLD quoted form (e.g. ["10","20"]) therefore
// both (a) hashes differently from the native re-flatten — a spurious
// stale flag — and (b) shows a spurious Changed attribute in the diff
// (DiffService.AttributesEqual is an ordinal Value comparison). Normalize
// the deserialized snapshot's List values through AttributeValueCodec
// Decode→Encode so an old-form value becomes native form and compares
// equal to the native re-flatten, then drive BOTH the staleness hash and
// the diff off that normalized snapshot. Scalars are left untouched.
//
// A snapshot that cannot be deserialized (corrupt /
// older schema) still yields the frozen-hash staleness result, with a
// null diff.
var deployedRevisionHash = snapshot.RevisionHash;
ConfigurationDiff? diff = null;
try
{
var deployedConfig = JsonSerializer.Deserialize(snapshot.ConfigurationJson);
if (deployedConfig != null)
{
deployedConfig = NormalizeListAttributeValues(deployedConfig);
// Recompute the deployed-side hash from the normalized snapshot so
// an old-form List value is not flagged stale against the native
// re-flatten. For a faithfully-stored scalar-only snapshot this
// reproduces the frozen RevisionHash exactly, so behaviour is
// unchanged outside the List-normalization case.
deployedRevisionHash = _revisionHashService.ComputeHash(deployedConfig);
diff = _diffService.ComputeDiff(
deployedConfig, currentConfig, deployedRevisionHash, 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 isStale = deployedRevisionHash != currentHash;
var result = new DeploymentComparisonResult(
instanceId,
deployedRevisionHash,
currentHash,
isStale,
snapshot.DeployedAt,
diff);
return Result.Success(result);
}
///
/// I-1 (latent): returns a copy of whose
/// attribute values have been round-tripped through
/// →
/// (native JSON-array form). This normalizes a value deployed in the OLD quoted
/// form (e.g. ["10","20"]) to the native form ([10,20]) the current
/// flattener now produces, so the staleness hash and the structured diff do not
/// report a spurious change. Scalar / string attributes are returned unchanged
/// (only is normalized). A value that cannot be
/// decoded (malformed JSON, bad element, or an unparseable element type) is left
/// as-is — a normalization failure must never break the read-only comparison.
///
private ResolvedAttribute NormalizeListAttribute(ResolvedAttribute attr)
{
if (!string.Equals(attr.DataType, nameof(DataType.List), StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrEmpty(attr.Value))
{
return attr;
}
if (!Enum.TryParse(attr.ElementDataType, ignoreCase: true, out var elementType)
|| !AttributeValueCodec.IsValidElementType(elementType))
{
return attr;
}
try
{
var normalized = AttributeValueCodec.Encode(
AttributeValueCodec.Decode(attr.Value, DataType.List, elementType));
return normalized == attr.Value ? attr : attr with { Value = normalized };
}
catch (FormatException ex)
{
// Best-effort: a snapshot value that no longer round-trips is left
// untouched rather than aborting the comparison. Logged so an operator
// can investigate the stored value.
_logger.LogWarning(ex,
"Could not normalize List attribute '{Attribute}' in deployed snapshot; " +
"comparing its stored value verbatim",
attr.CanonicalName);
return attr;
}
}
///
/// I-1 (latent): applies to every attribute
/// in , returning the original instance unchanged when
/// no List value needed normalizing (the common scalar-only case).
///
private FlattenedConfiguration NormalizeListAttributeValues(FlattenedConfiguration config)
{
if (config.Attributes.Count == 0)
return config;
var normalized = config.Attributes.Select(NormalizeListAttribute).ToList();
var changed = normalized.Where((a, i) => !ReferenceEquals(a, config.Attributes[i])).Any();
return changed ? config with { Attributes = normalized } : config;
}
///
/// Returns the current persisted 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 , which
/// invokes on the deploy path.
///
/// The unique deployment identifier to look up.
/// Cancellation token for the operation.
/// A task that resolves to the matching deployment record, or null if none exists.
public async Task GetDeploymentStatusAsync(
string deploymentId,
CancellationToken cancellationToken = default)
{
return await _repository.GetDeploymentByDeploymentIdAsync(deploymentId, cancellationToken);
}
///
/// Query-the-site-before-redeploy reconciliation.
///
/// The site query is issued ONLY when a prior
/// for this instance is stuck , or
/// is 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 skip the extra round-trip.
///
/// Reconciliation: if the site already has the TARGET revision hash, the
/// prior record is marked (with its
/// corrected to the target)
/// and returned (the caller must NOT re-send the
/// deploy). The same post-success side effects as the normal deploy path
/// are applied — instance and a stored
/// — so central
/// and site state do not diverge. Otherwise null is returned and the
/// normal deploy proceeds.
///
/// Query failure: if the site is unreachable or the query times out, this
/// returns null (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.
///
private async Task 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;
// 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);
// 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.
//
// 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);
// 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;
}
///
/// The site is queried before a re-deploy only when a
/// prior record is stuck , or is
/// because the site command timed out
/// (detected via the error-message
/// marker). All other prior states skip the query.
///
private static bool ShouldQuerySiteBeforeRedeploy(DeploymentRecord prior) =>
prior.Status == DeploymentStatus.InProgress
|| (prior.Status == DeploymentStatus.Failed
&& prior.ErrorMessage != null
&& prior.ErrorMessage.StartsWith(TimeoutFailurePrefix, StringComparison.Ordinal));
///
/// Post-success side effects shared by the normal deploy path and the
/// reconciliation path: set the instance
/// and store/refresh the
/// deployed config snapshot. Factored into one helper so the two
/// paths cannot drift.
///
/// distinguishes
/// the two callers. The normal deploy path passes true — a fresh
/// successful apply legitimately puts the instance into
/// (the documented "Deploy on a Disabled instance also enables it" semantics
/// of ). The reconciliation path
/// passes false: 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
/// →
/// (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
/// 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.
///
private async Task ApplyPostSuccessSideEffectsAsync(
Instance instance,
string deploymentId,
string revisionHash,
string configJson,
bool forceEnabledState,
CancellationToken cancellationToken)
{
try
{
// Update instance state to Enabled on successful deployment.
// 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);
// 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);
}
}
///
/// Write a "<Action>TimedOut" audit entry on
/// behalf of a lifecycle command (Disable / Enable / Delete) whose site
/// round-trip exceeded .
///
///
/// Mirrors the DeployFailed pattern in
/// : the audit write uses
/// 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.
///
///
///
/// 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.
///
///
/// The username who initiated the lifecycle command.
/// The audit action name (DisableTimedOut, EnableTimedOut, or DeleteTimedOut).
/// The numeric instance id, recorded on the audit row.
/// The instance unique name used as the audit target name.
/// The lifecycle command's correlation id, so the audit entry can be matched to logs.
/// The captured or .
///
/// Best-effort audit write for a post-terminal-status action. By the time this
/// runs, the deployment/lifecycle action has already committed its authoritative
/// result; a failed audit write must be logged for operator reconciliation but
/// must NEVER surface to the caller or flip the committed status — "audit-write
/// failure NEVER aborts the user-facing action" (Centralized Audit Log design).
/// Any exception is swallowed and logged at Warning.
///
private async Task TryLogAuditAsync(
string user,
string action,
string entityType,
string entityId,
string entityName,
object? afterState,
CancellationToken cancellationToken)
{
try
{
await _auditService.LogAsync(
user, action, entityType, entityId, entityName, afterState, cancellationToken);
}
catch (Exception auditEx)
{
_logger.LogWarning(auditEx,
"Failed to write {Action} audit entry for {EntityType} {EntityName} -- the action " +
"itself succeeded and its committed status is authoritative; audit is best-effort",
action, entityType, entityName);
}
}
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);
}
}
}
///
/// Result of comparing deployed vs template-derived configuration.
///
///
/// 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
/// still reflects the hash comparison.
///
public record DeploymentComparisonResult(
int InstanceId,
string DeployedRevisionHash,
string CurrentRevisionHash,
bool IsStale,
DateTimeOffset DeployedAt,
ConfigurationDiff? Diff = null);