404 lines
18 KiB
C#
404 lines
18 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
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.Types.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
|
|
/// <summary>
|
|
/// EF Core implementation of <see cref="IDeploymentManagerRepository"/> covering
|
|
/// the deployment pipeline's persistence surface: <c>DeploymentRecord</c> CRUD
|
|
/// (with optimistic concurrency via <c>DeploymentRecord.RowVersion</c>),
|
|
/// <c>SystemArtifactDeploymentRecord</c> CRUD, <c>DeployedConfigSnapshot</c> CRUD,
|
|
/// and a Restrict-FK-aware <see cref="DeleteInstanceAsync"/> that explicitly
|
|
/// clears dependent deployment-record rows before removing an instance.
|
|
/// </summary>
|
|
public class DeploymentManagerRepository : IDeploymentManagerRepository
|
|
{
|
|
private readonly ScadaBridgeDbContext _dbContext;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the DeploymentManagerRepository class.
|
|
/// </summary>
|
|
/// <param name="dbContext">The database context for accessing deployment data.</param>
|
|
public DeploymentManagerRepository(ScadaBridgeDbContext dbContext)
|
|
{
|
|
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
|
}
|
|
|
|
// --- DeploymentRecord ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeploymentRecord?> GetDeploymentRecordByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.DeploymentRecords.FindAsync([id], cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<DeploymentRecord>> GetAllDeploymentRecordsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.DeploymentRecords
|
|
.OrderByDescending(d => d.DeployedAt)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<DeploymentRecord>> GetDeploymentsByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.DeploymentRecords
|
|
.Where(d => d.InstanceId == instanceId)
|
|
.OrderByDescending(d => d.DeployedAt)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeploymentRecord?> GetCurrentDeploymentStatusAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
// DeploymentManager-026: deployments are insert-only (one row per deploy
|
|
// attempt), so two records for the same instance can tie on DeployedAt when
|
|
// they are created within the same clock tick (a rapid redeploy, or a
|
|
// redeploy immediately after a timed-out attempt). SQL Server's choice
|
|
// between equal sort keys is undefined, so reconciliation could read the
|
|
// wrong "current" record. ThenByDescending(d => d.Id) makes the read
|
|
// deterministic — the highest Id (the most recently inserted row) wins.
|
|
return await _dbContext.DeploymentRecords
|
|
.Where(d => d.InstanceId == instanceId)
|
|
.OrderByDescending(d => d.DeployedAt)
|
|
.ThenByDescending(d => d.Id)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeploymentRecord?> GetDeploymentByDeploymentIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.DeploymentRecords
|
|
.FirstOrDefaultAsync(d => d.DeploymentId == deploymentId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
|
{
|
|
await _dbContext.DeploymentRecords.AddAsync(record, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateDeploymentRecordAsync(DeploymentRecord record, CancellationToken cancellationToken = default)
|
|
{
|
|
_dbContext.DeploymentRecords.Update(record);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(expectedRowVersion);
|
|
|
|
// CD-017: DeploymentRecord carries a SQL Server rowversion concurrency token.
|
|
// The stub-attach delete path must seed EF's OriginalValues["RowVersion"] with
|
|
// the caller's last-observed value so the generated SQL becomes
|
|
// `DELETE ... WHERE Id = @id AND RowVersion = @prior`. Without this seeding a
|
|
// concurrent edit is silently overwritten; with it, EF raises
|
|
// DbUpdateConcurrencyException on SaveChangesAsync — the documented
|
|
// optimistic-concurrency contract on deployment status records.
|
|
var record = _dbContext.DeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
|
if (record != null)
|
|
{
|
|
var entry = _dbContext.Entry(record);
|
|
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
|
_dbContext.DeploymentRecords.Remove(record);
|
|
}
|
|
else
|
|
{
|
|
var stub = new DeploymentRecord("stub", "stub") { Id = id };
|
|
_dbContext.DeploymentRecords.Attach(stub);
|
|
var entry = _dbContext.Entry(stub);
|
|
entry.OriginalValues["RowVersion"] = expectedRowVersion;
|
|
_dbContext.DeploymentRecords.Remove(stub);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- SystemArtifactDeploymentRecord ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task<SystemArtifactDeploymentRecord?> GetSystemArtifactDeploymentByIdAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.SystemArtifactDeploymentRecords.FindAsync([id], cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<SystemArtifactDeploymentRecord>> GetAllSystemArtifactDeploymentsAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.SystemArtifactDeploymentRecords
|
|
.OrderByDescending(d => d.DeployedAt)
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
|
{
|
|
await _dbContext.SystemArtifactDeploymentRecords.AddAsync(record, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateSystemArtifactDeploymentAsync(SystemArtifactDeploymentRecord record, CancellationToken cancellationToken = default)
|
|
{
|
|
_dbContext.SystemArtifactDeploymentRecords.Update(record);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task DeleteSystemArtifactDeploymentAsync(int id, CancellationToken cancellationToken = default)
|
|
{
|
|
var record = _dbContext.SystemArtifactDeploymentRecords.Local.FirstOrDefault(d => d.Id == id);
|
|
if (record != null)
|
|
{
|
|
_dbContext.SystemArtifactDeploymentRecords.Remove(record);
|
|
}
|
|
else
|
|
{
|
|
var stub = new SystemArtifactDeploymentRecord("stub", "stub") { Id = id };
|
|
_dbContext.SystemArtifactDeploymentRecords.Attach(stub);
|
|
_dbContext.SystemArtifactDeploymentRecords.Remove(stub);
|
|
}
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
// --- WP-8: DeployedConfigSnapshot ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeployedConfigSnapshot?> GetDeployedSnapshotByInstanceIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.Set<DeployedConfigSnapshot>()
|
|
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
|
|
{
|
|
await _dbContext.Set<DeployedConfigSnapshot>().AddAsync(snapshot, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateDeployedSnapshotAsync(DeployedConfigSnapshot snapshot, CancellationToken cancellationToken = default)
|
|
{
|
|
_dbContext.Set<DeployedConfigSnapshot>().Update(snapshot);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteDeployedSnapshotAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
var snapshot = await _dbContext.Set<DeployedConfigSnapshot>()
|
|
.FirstOrDefaultAsync(s => s.InstanceId == instanceId, cancellationToken);
|
|
if (snapshot != null)
|
|
{
|
|
_dbContext.Set<DeployedConfigSnapshot>().Remove(snapshot);
|
|
}
|
|
}
|
|
|
|
// --- Notify-and-fetch: PendingDeployment staging store ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task AddPendingDeploymentAsync(PendingDeployment pending, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(pending);
|
|
|
|
// Supersession: a newer deploy for the same instance replaces any prior pending
|
|
// row. Safe because the per-instance operation lock serializes same-instance
|
|
// deploys. SaveChanges is deferred to the caller, matching the other Add methods.
|
|
var prior = await _dbContext.Set<PendingDeployment>()
|
|
.Where(p => p.InstanceId == pending.InstanceId)
|
|
.ToListAsync(cancellationToken);
|
|
if (prior.Count > 0)
|
|
{
|
|
_dbContext.Set<PendingDeployment>().RemoveRange(prior);
|
|
}
|
|
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<PendingDeployment?> GetPendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
|
{
|
|
return _dbContext.Set<PendingDeployment>()
|
|
.FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task<PendingDeployment?> GetPendingDeploymentByInstanceIdAsync(int instanceId, DateTimeOffset? nowUtc = null, CancellationToken cancellationToken = default)
|
|
{
|
|
// At most one pending row per instance by design (supersession + stage-if-absent),
|
|
// but order deterministically and take the most recent so a hypothetical duplicate
|
|
// never makes the read non-deterministic — mirrors GetCurrentDeploymentStatusAsync.
|
|
var query = _dbContext.Set<PendingDeployment>()
|
|
.Where(p => p.InstanceId == instanceId);
|
|
|
|
// Expiry-aware: never hand back an EXPIRED row. Pending rows are only TTL-purged, so an
|
|
// expired-but-unpurged row would otherwise return a token the config-fetch endpoint 404s
|
|
// (it correctly rejects expired rows), leaving the node unhealed.
|
|
if (nowUtc.HasValue)
|
|
{
|
|
var now = nowUtc.Value;
|
|
query = query.Where(p => p.ExpiresAtUtc > now);
|
|
}
|
|
|
|
return query
|
|
.OrderByDescending(p => p.CreatedAtUtc)
|
|
.ThenByDescending(p => p.Id)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeletePendingDeploymentByIdAsync(string deploymentId, CancellationToken cancellationToken = default)
|
|
{
|
|
var row = await _dbContext.Set<PendingDeployment>()
|
|
.FirstOrDefaultAsync(p => p.DeploymentId == deploymentId, cancellationToken);
|
|
if (row != null)
|
|
{
|
|
_dbContext.Set<PendingDeployment>().Remove(row);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> PurgeExpiredPendingDeploymentsAsync(DateTimeOffset nowUtc, CancellationToken cancellationToken = default)
|
|
{
|
|
// Self-contained TTL maintenance op: commits its own delete and returns the count.
|
|
var expired = await _dbContext.Set<PendingDeployment>()
|
|
.Where(p => p.ExpiresAtUtc <= nowUtc)
|
|
.ToListAsync(cancellationToken);
|
|
if (expired.Count == 0)
|
|
{
|
|
return 0;
|
|
}
|
|
_dbContext.Set<PendingDeployment>().RemoveRange(expired);
|
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
return expired.Count;
|
|
}
|
|
|
|
// --- Startup reconciliation ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<ExpectedDeployment>> GetExpectedDeploymentsForSiteAsync(int siteId, CancellationToken cancellationToken = default)
|
|
{
|
|
// Inner join: only instances that have a DeployedConfigSnapshot are returned.
|
|
// ConfigurationJson is intentionally excluded — the caller fetches the full config
|
|
// on demand via the HTTP endpoint for gap instances only.
|
|
return await (
|
|
from snap in _dbContext.Set<DeployedConfigSnapshot>()
|
|
join inst in _dbContext.Set<Instance>() on snap.InstanceId equals inst.Id
|
|
where inst.SiteId == siteId
|
|
select new ExpectedDeployment(
|
|
inst.Id,
|
|
inst.UniqueName,
|
|
snap.RevisionHash,
|
|
snap.DeploymentId,
|
|
inst.State == InstanceState.Enabled))
|
|
.ToListAsync(cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<bool> StagePendingIfAbsentAsync(
|
|
int instanceId, string deploymentId, string revisionHash,
|
|
string configurationJson, string token,
|
|
DateTimeOffset createdAtUtc, DateTimeOffset expiresAtUtc,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Treat createdAtUtc as "now". FIRST remove any EXPIRED pending rows for this instance
|
|
// (ExpiresAtUtc <= now). Pending rows are only TTL-purged (the periodic purge is still a
|
|
// deferred TODO), so an EXPIRED-but-unpurged row would otherwise (a) read as "a deploy is
|
|
// in flight" and block staging — handing the node an expired token (HTTP 404) and leaving
|
|
// it unhealed — and (b) collide on the DeploymentId UNIQUE index when a reconcile re-stages
|
|
// the snapshot's own DeploymentId. Dropping expired rows first fixes both.
|
|
var expired = await _dbContext.Set<PendingDeployment>()
|
|
.Where(p => p.InstanceId == instanceId && p.ExpiresAtUtc <= createdAtUtc)
|
|
.ToListAsync(cancellationToken);
|
|
if (expired.Count > 0)
|
|
{
|
|
_dbContext.Set<PendingDeployment>().RemoveRange(expired);
|
|
}
|
|
|
|
// THEN insert-if-absent against still-LIVE rows only. A live pending row means a genuine
|
|
// in-flight deploy (or a concurrent reconcile) already owns the slot — do NOT supersede it;
|
|
// clobbering it could make the node fetch the reconcile token while the original deliver is
|
|
// mid-flight. (Expired rows just removed are disjoint from this future-expiry predicate.)
|
|
var liveExists = await _dbContext.Set<PendingDeployment>()
|
|
.AnyAsync(p => p.InstanceId == instanceId && p.ExpiresAtUtc > createdAtUtc, cancellationToken);
|
|
if (!liveExists)
|
|
{
|
|
var pending = new PendingDeployment(
|
|
deploymentId, instanceId, revisionHash,
|
|
configurationJson, token, createdAtUtc, expiresAtUtc);
|
|
await _dbContext.Set<PendingDeployment>().AddAsync(pending, cancellationToken);
|
|
}
|
|
|
|
// Self-contained: one SaveChanges flushes the expired-row cleanup and, when staged, the new
|
|
// row together (EF orders the delete before the same-DeploymentId insert to satisfy the
|
|
// unique index). Returns true only when a fresh row was staged.
|
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
return !liveExists;
|
|
}
|
|
|
|
// --- Instance lookups for deployment pipeline ---
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByIdAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.Set<Instance>()
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Instance?> GetInstanceByUniqueNameAsync(string uniqueName, CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.Set<Instance>()
|
|
.Include(i => i.AttributeOverrides)
|
|
.Include(i => i.AlarmOverrides)
|
|
.Include(i => i.ConnectionBindings)
|
|
.Include(i => i.NativeAlarmSourceOverrides)
|
|
.AsSplitQuery()
|
|
.FirstOrDefaultAsync(i => i.UniqueName == uniqueName, cancellationToken);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task UpdateInstanceAsync(Instance instance, CancellationToken cancellationToken = default)
|
|
{
|
|
_dbContext.Set<Instance>().Update(instance);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task DeleteInstanceAsync(int instanceId, CancellationToken cancellationToken = default)
|
|
{
|
|
// DeploymentRecords have a Restrict FK to Instance — remove them
|
|
// explicitly first. The snapshot, overrides, and connection bindings
|
|
// are configured with cascade delete and go with the instance.
|
|
var records = await _dbContext.DeploymentRecords
|
|
.Where(d => d.InstanceId == instanceId)
|
|
.ToListAsync(cancellationToken);
|
|
if (records.Count > 0)
|
|
{
|
|
_dbContext.DeploymentRecords.RemoveRange(records);
|
|
}
|
|
|
|
var instance = await _dbContext.Set<Instance>()
|
|
.FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken);
|
|
if (instance != null)
|
|
{
|
|
_dbContext.Set<Instance>().Remove(instance);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
return await _dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|