9f67147e7b
Deferred-work register rows 37, 86, 87, 88 — the structural duplication and the three behavioral divergences it had accumulated, fixed together. Row 37 (structural). `IDeploymentManagerRepository.QueryDeploymentSummariesAsync` is DELETED. It and `QueryDeploymentListPageAsync` were two paged reads of the same insert-only table with different contracts — raw offset vs page number, no total vs total, no instance join vs joined, instance-id-list scoping vs site-id scoping. The CLI/HTTP `QueryDeployments` handler now goes through `QueryDeploymentListPageAsync` with a `DeploymentListFilter`, exactly like the Central UI page. The `DeploymentRecordSummary` projection went with it (`DeploymentListRow` is the single row shape); its `MaxPageSize` const moved to `DeploymentListPage`, which also gains `DefaultPageSize`. Row 86 (Medium, authorization — the headline). `SiteScopeService.ResolveAsync` was claims-only, so an Administrator carrying `SiteId` claims saw the whole fleet through the CLI (`ManagementActor.EnforceSiteScope` has always exempted Administrator) and only their granted sites on the Deployments page. One user, one dataset, two answers. Administrator = system-wide is the intended semantic on both sides; the UI was the defect. The role claim is read explicitly with an OrdinalIgnoreCase comparison rather than via `IsInRole`, which compares claim VALUES ordinally and only resolves when the identity carries the matching roleType — the two sides must not disagree on a differently-cased role name. An Administrator's site claims are not collected at all, so the invariant "system-wide => empty permitted-id set" holds for every caller. Designer is NOT exempt: the class doc claimed "any Admin/Design user" is system-wide, which was true of neither side, so the doc was corrected rather than the bypass widened. Row 87 (Low). The scoped branch used to resolve the site grant into an instance-id list by loading EVERY instance in the system, non-transactionally, on every scoped query; the grant now rides as `SiteIdScope` and is resolved by the query's own join. An unparseable permitted-site id is DROPPED, never ignored — dropping can only narrow a grant, discarding it would widen it to fleet-wide. And `PageSize <= 0` no longer means UNLIMITED down at the repository (`--page-size 0` materialized the whole retention window): it means "unspecified" and takes the CLI's own default of 50, capped at 1000. Bounding without telling would be silent truncation, so the handler now returns a `DeploymentQueryResult` envelope carrying `totalCount` plus the page coordinates actually applied — with a CLI table projection (mirroring row 57's `AuditConfigTableProjection`) that unwraps it and appends a "showing N of M" note. Row 88 (Low, UX). An unrecognised `--status` fell back to "no filter", so a typo like `--status Sucess` returned every status — a complete-looking answer to a question the caller never asked. It is now a `ManagementError` naming the valid values. Parsing stays case-insensitive; a numeric string is rejected too, since `Enum.TryParse` accepts "7" and yields an undefined value (guarded with `Enum.IsDefined`). Loud failure over silent full results, per row 46. Tests: 7 new `SiteScopeServiceTests` pinning Administrator = system-wide (verified failing 6/7 against the pre-fix service, the Designer pin passing both ways); the ManagementActor deployment suite migrated onto the consolidated query with new pins for the status error, case-insensitive parse, numeric rejection, page-size default/ cap/floor, empty-scope-on-garbage-grant, and "reads no instances at all"; a repository test for site-scope+status composing over a bounded page; and 6 CLI tests for the new table projection.
577 lines
25 KiB
C#
577 lines
25 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared filtered join behind <see cref="QueryDeploymentListPageAsync"/> and
|
|
/// <see cref="GetDeploymentStatusCountsAsync"/>, so the two reads that back one
|
|
/// screen can never disagree about which rows they describe.
|
|
///
|
|
/// <para>
|
|
/// The join to <c>Instance</c> is an INNER join, which is exact rather than
|
|
/// merely convenient: <c>DeploymentRecord</c> has a <c>Restrict</c> FK to
|
|
/// <c>Instance</c> and <see cref="DeleteInstanceAsync"/> removes the records
|
|
/// before the instance, so an orphaned deployment record cannot exist. The join
|
|
/// is what lets site scoping run in SQL — <c>DeploymentRecord</c> itself has no
|
|
/// <c>SiteId</c>.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="filter">Filter to apply.</param>
|
|
/// <param name="applyStatus">
|
|
/// When <see langword="false"/>, the status constraint is skipped — the
|
|
/// status-count aggregation needs every status of the otherwise-identical set.
|
|
/// </param>
|
|
/// <returns>The composed, unordered, untracked query.</returns>
|
|
private IQueryable<DeploymentInstanceJoin> BuildListQuery(DeploymentListFilter filter, bool applyStatus)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
|
|
var query =
|
|
from d in _dbContext.DeploymentRecords.AsNoTracking()
|
|
join i in _dbContext.Set<Instance>().AsNoTracking() on d.InstanceId equals i.Id
|
|
select new DeploymentInstanceJoin
|
|
{
|
|
Id = d.Id,
|
|
DeploymentId = d.DeploymentId,
|
|
InstanceId = d.InstanceId,
|
|
InstanceUniqueName = i.UniqueName,
|
|
SiteId = i.SiteId,
|
|
Status = d.Status,
|
|
RevisionHash = d.RevisionHash,
|
|
DeployedBy = d.DeployedBy,
|
|
DeployedAt = d.DeployedAt,
|
|
CompletedAt = d.CompletedAt,
|
|
ErrorMessage = d.ErrorMessage
|
|
};
|
|
|
|
if (filter.InstanceId.HasValue)
|
|
query = query.Where(r => r.InstanceId == filter.InstanceId.Value);
|
|
|
|
if (applyStatus && filter.Status.HasValue)
|
|
query = query.Where(r => r.Status == filter.Status.Value);
|
|
|
|
if (filter.SiteIdScope != null)
|
|
{
|
|
// An EMPTY scope is a real filter — a user permitted no sites sees
|
|
// nothing — so it must not be short-circuited into "no filter".
|
|
var scope = filter.SiteIdScope as int[] ?? filter.SiteIdScope.ToArray();
|
|
query = query.Where(r => scope.Contains(r.SiteId));
|
|
}
|
|
|
|
if (filter.NormalizedSearch is { } search)
|
|
{
|
|
// Substring match across the four identifiers a deployment is findable
|
|
// by. Translated to LIKE '%term%' by both providers; case-insensitivity
|
|
// is the database collation's (SQL Server default CI, SQLite ASCII LIKE).
|
|
query = query.Where(r =>
|
|
r.InstanceUniqueName.Contains(search)
|
|
|| r.DeploymentId.Contains(search)
|
|
|| r.DeployedBy.Contains(search)
|
|
|| (r.RevisionHash != null && r.RevisionHash.Contains(search)));
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeploymentListPage> QueryDeploymentListPageAsync(
|
|
DeploymentListFilter filter,
|
|
int pageNumber,
|
|
int pageSize,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var page = Math.Max(1, pageNumber);
|
|
var size = Math.Clamp(pageSize, 1, DeploymentListPage.MaxPageSize);
|
|
|
|
var query = BuildListQuery(filter, applyStatus: true);
|
|
|
|
// Count first: the pager needs a page count, and the caller must be able to
|
|
// tell "page 7 is now past the end" (a concurrent purge or a narrowed filter)
|
|
// from "page 7 is empty".
|
|
var totalCount = await query.CountAsync(cancellationToken);
|
|
|
|
// DeployedAt ties on rapid redeploys; without the Id tie-break the sort key
|
|
// is unstable and offset paging repeats or drops rows between pages.
|
|
var rows = await query
|
|
.OrderByDescending(r => r.DeployedAt)
|
|
.ThenByDescending(r => r.Id)
|
|
.Skip((page - 1) * size)
|
|
.Take(size)
|
|
.Select(r => new DeploymentListRow(
|
|
r.Id,
|
|
r.DeploymentId,
|
|
r.InstanceId,
|
|
r.InstanceUniqueName,
|
|
r.SiteId,
|
|
r.Status,
|
|
r.RevisionHash,
|
|
r.DeployedBy,
|
|
r.DeployedAt,
|
|
r.CompletedAt,
|
|
r.ErrorMessage))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return new DeploymentListPage(rows, totalCount);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DeploymentStatusCounts> GetDeploymentStatusCountsAsync(
|
|
DeploymentListFilter filter,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// applyStatus: false — the tiles are the status breakdown of the filtered
|
|
// set, so the active status filter is the one dimension they must NOT honour.
|
|
var grouped = await BuildListQuery(filter, applyStatus: false)
|
|
.GroupBy(r => r.Status)
|
|
.Select(g => new { Status = g.Key, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return DeploymentStatusCounts.FromGrouped(grouped.Select(g => (g.Status, g.Count)));
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
// 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);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// --- 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;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> PurgeTerminalDeploymentRecordsAsync(
|
|
DateTimeOffset cutoffUtc,
|
|
int batchSize,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
// Terminal statuses ONLY. An InProgress (or Pending) row is never purged
|
|
// regardless of age: it is precisely the row TryReconcileWithSiteAsync
|
|
// looks for when deciding whether a prior deploy actually landed, and
|
|
// deleting it would silently disable the query-before-redeploy idempotency
|
|
// guard for that instance.
|
|
//
|
|
// Bounded batch rather than one unbounded DELETE so the first purge on a
|
|
// long-lived system cannot take a table-scale lock on DeploymentRecords.
|
|
// The caller re-invokes until it returns fewer rows than the batch size.
|
|
var stale = await _dbContext.DeploymentRecords
|
|
.Where(d => d.CompletedAt != null
|
|
&& d.CompletedAt < cutoffUtc
|
|
&& (d.Status == DeploymentStatus.Success || d.Status == DeploymentStatus.Failed))
|
|
.OrderBy(d => d.Id)
|
|
.Take(batchSize)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (stale.Count == 0)
|
|
return 0;
|
|
|
|
_dbContext.DeploymentRecords.RemoveRange(stale);
|
|
await _dbContext.SaveChangesAsync(cancellationToken);
|
|
return stale.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Flat shape of the deployment-record ⋈ instance join used to compose the list
|
|
/// query. A named type rather than an anonymous one so
|
|
/// <see cref="BuildListQuery"/> can hand the composed-but-unexecuted query back
|
|
/// to both the paging read and the count aggregation, keeping the two in
|
|
/// lockstep. Not a public contract — <see cref="DeploymentListRow"/> is what
|
|
/// leaves this class.
|
|
/// </summary>
|
|
private sealed class DeploymentInstanceJoin
|
|
{
|
|
public int Id { get; init; }
|
|
public string DeploymentId { get; init; } = string.Empty;
|
|
public int InstanceId { get; init; }
|
|
public string InstanceUniqueName { get; init; } = string.Empty;
|
|
public int SiteId { get; init; }
|
|
public DeploymentStatus Status { get; init; }
|
|
public string? RevisionHash { get; init; }
|
|
public string DeployedBy { get; init; } = string.Empty;
|
|
public DateTimeOffset DeployedAt { get; init; }
|
|
public DateTimeOffset? CompletedAt { get; init; }
|
|
public string? ErrorMessage { get; init; }
|
|
}
|
|
}
|