Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ServiceCollectionExtensions.cs
T
Joseph Doherty a4806576af feat(pending-updates): short-circuit an unchanged fleet on a DB rowversion watermark (#62)
Owner decision (b), 2026-11-09. PendingUpdatesEvaluator re-flattened every
deployed instance on each cache-TTL expiry even when nothing had changed.

- Migration AddConfigGraphRowVersions: a non-concurrency rowversion column
  + index on the 18 configuration tables the evaluation reads (Templates +
  5 member tables, Instances + 4 override/binding tables, DataConnections,
  DeployedConfigSnapshots, Sites, SharedScripts, ExternalSystemDefinitions,
  ExternalSystemMethods, DatabaseConnectionDefinitions). SQL Server only in
  the model; SQLite test contexts are untouched. Script:
  docs/plans/sql/AddConfigGraphRowVersions.sql. Joins the coordinated
  central-pair migration window (#107 item 4).
- IConfigGraphWatermarkReader / ConfigGraphWatermarkReader: ONE query of
  MAX(RowVersion) + COUNT_BIG(*) per table (count catches deletes, which
  raise no rowversion). rowversion is engine-bumped for any writer, so a
  peer central node's edit moves it — not the per-process watermark MG-3
  removed.
- The secret store (outside the DB) gets its own stamp: SHA-256 over
  ListAsync(includeDeleted: true) metadata.
- Evaluator re-serves the last CLEAN snapshot while watermark + secret
  stamp are unchanged; every PendingUpdatesFullEvaluationEvery'th (default
  10, validated >= 1, 1 = off) is full regardless — the safety net for a
  same-KEK re-wrap or an input table missing from the set. Always full when
  the token is unavailable/unreadable, after an evaluation that could not
  compute a site's artifact hash, on Re-check, and after a deploy completion.
  It only re-serves a real earlier answer, never manufactures one.

Tests: SQL Server (real migrated DB) — token stable without writes, moves
for a peer-connection INSERT/UPDATE and a non-max DELETE, moves for a local
EF write, a detached Update still saves (not a concurrency token), and a
guard that every table the evaluator's repository reads touch is covered.
Evaluator — reuse without flatten (re-served answer still shows the stale
instance), watermark move, 4 secret changes, every-Nth, 5 no-token modes,
unclean evaluation not reused, cache Re-check and deploy completion force
full. Mutation-proven: token compare, clean check, cache force flag, row
count, and an uncovered read each turn tests red.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 22:38:55 -05:00

168 lines
11 KiB
C#

using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the ScadaBridgeDbContext with the provided SQL Server connection string.
/// </summary>
/// <param name="services">The service collection to register into.</param>
/// <param name="connectionString">SQL Server connection string for the central configuration database.</param>
/// <returns>The same <paramref name="services"/> collection, for chaining.</returns>
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
{
// The DbContext is constructed via the (options, IDataProtectionProvider) overload so
// secret-bearing configuration columns are encrypted at rest. AddDataProtection below
// registers IDataProtectionProvider as a singleton; resolving it here does not recurse
// because key-ring loading is lazy (first Protect/Unprotect), not triggered by
// CreateProtector during model building.
//
// POOLING IS DELIBERATELY NOT USED (verified, not overlooked).
// AddDbContextPool requires a context with a SINGLE public constructor taking
// only DbContextOptions<TContext>; EF Core constructs pooled instances through
// its own activator and cannot supply anything else. ScadaBridgeDbContext has
// two public constructors and the runtime one takes IDataProtectionProvider,
// because the encrypting value converter for secret-bearing columns is built
// during OnModelCreating from that provider. Worse, the model itself DIFFERS
// between the two constructors (no provider ⇒ no encrypting converter), so a
// pooled activator would silently produce a context that reads secret columns
// as ciphertext. Making this poolable means moving the protector out of the
// constructor and into a DbContextOptions extension — a change to the
// secrets-at-rest path, which is not a performance refactor. The registration
// below (a scoped factory overriding AddDbContext's activator) is what makes
// the provider reach the context at all, and it also bypasses pooling by
// construction. Revisit only alongside a deliberate secrets-plumbing change.
services.AddDbContext<ScadaBridgeDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(
connectionString,
sql => sql.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning));
});
// AddDbContext registers ScadaBridgeDbContext via EF's activator, which only injects
// DbContextOptions. Override that registration (last registration wins for resolution)
// with a factory that also supplies the IDataProtectionProvider, so the encrypting
// value converter for secret columns is always wired up at runtime.
services.AddScoped(serviceProvider =>
{
var options = serviceProvider.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>();
var protectionProvider = serviceProvider.GetRequiredService<IDataProtectionProvider>();
return new ScadaBridgeDbContext(options, protectionProvider);
});
// Process-wide template/instance version watermark. Singleton because the
// writer (TemplateEngineRepository.SaveChangesAsync, scoped) and the readers
// (the flatten-session cache and the staleness fast path, also scoped) must
// observe the same counters across DI scopes. TryAdd so the DeploymentManager
// registration of the same interface is idempotent.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddSingleton<IConfigurationCommandTransaction, ConfigurationCommandTransaction>();
services.AddScoped<ConfigurationCommitActions>();
services.AddScoped<IConfigurationCommitActions>(sp => sp.GetRequiredService<ConfigurationCommitActions>());
services.AddScoped<IConfigurationReadSession, ConfigurationReadSession>();
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
services.AddScoped<IDeploymentManagerRepository, DeploymentManagerRepository>();
services.AddScoped<IConfigGraphWatermarkReader, ConfigGraphWatermarkReader>();
services.AddScoped<ISiteRepository, SiteRepository>();
services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
services.AddScoped<INotificationRepository, NotificationRepository>();
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
services.AddScoped<ISecuredWriteRepository, SecuredWriteRepository>();
services.AddScoped<ISharedSchemaRepository, SharedSchemaRepository>();
services.AddScoped<IKpiHistoryRepository, KpiHistoryRepository>();
// Register row 52: the fleet-wide bundle-unlock throttle window. Central-only in
// practice (Transport is registered in the host's Central branch), but registered
// here with every other repository so the ConfigurationDatabase surface stays uniform.
services.AddScoped<IBundleUnlockAttemptRepository, BundleUnlockAttemptRepository>();
// Auth re-arch: inbound API keys are no longer persisted in SQL Server —
// the repository now exposes only API-method access, so a plain scoped
// registration suffices (no peppered-hasher accessor to wire).
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
services.AddScoped<IAuditService, AuditService>();
services.AddScoped<IInstanceLocator, InstanceLocator>();
// IPartitionMaintenance drives the daily roll-forward
// of pf_AuditLog_Month from the central AuditLogPartitionMaintenanceService
// hosted service. Scoped because the implementation reuses the per-scope
// ScadaBridgeDbContext for raw-SQL execution; the hosted service opens a
// fresh scope on each tick (mirrors AuditLogPurgeActor / AuditLogIngestActor).
services.AddScoped<IPartitionMaintenance, AuditLogPartitionMaintenance>();
// AuditLog hash-chain tamper evidence (ScadaBridge#41): chained by the central
// AuditLogHashChainService, verified by the VerifyAuditChain management command.
services.AddScoped<IAuditLogHashChain, AuditLogHashChain>();
// AuditLog Parquet cold archive (ScadaBridge#42): written by the purge actor before a
// partition switch, listed/verified by the ListAuditArchives / VerifyAuditArchive
// management commands. AuditLog:Archive is bound by AddAuditLogCentral; without it the
// defaults apply (disabled, no directory).
services.AddScoped<IAuditLogArchive>(sp => new AuditLogPartitionArchive(
sp.GetRequiredService<ScadaBridgeDbContext>(),
sp.GetService<Microsoft.Extensions.Options.IOptions<AuditLogArchiveOptions>>()?.Value ?? new AuditLogArchiveOptions(),
sp.GetService<Microsoft.Extensions.Logging.ILogger<AuditLogPartitionArchive>>()));
// Opt-in config-audit retention (ScadaBridge:ConfigAudit:RetentionDays, default 0 = off).
// Options binding to configuration is done by the host; without it the defaults apply
// and the purge service is a no-op.
services.TryAddEnumerable(ServiceDescriptor.Singleton<
Microsoft.Extensions.Options.IValidateOptions<ConfigAuditOptions>, ConfigAuditOptionsValidator>());
services.TryAddSingleton(TimeProvider.System);
services.AddHostedService<ConfigAuditPurgeService>();
services.AddDataProtection()
.PersistKeysToDbContext<ScadaBridgeDbContext>();
return services;
}
/// <summary>
/// Obsolete parameterless overload. This previously registered nothing, which meant a
/// central node wired up with it failed late and opaquely — the first repository
/// resolution threw a DI exception far from the actual misconfiguration. Use
/// <see cref="AddConfigurationDatabase(IServiceCollection, string)"/> and pass the
/// configured connection string.
/// </summary>
/// <param name="services">The service collection (unused; this overload always throws).</param>
/// <exception cref="InvalidOperationException">
/// Always thrown. The connection string is required; there is no valid no-op registration.
/// </exception>
/// <returns>Never returns; always throws <see cref="InvalidOperationException"/>.</returns>
[Obsolete(
"AddConfigurationDatabase() with no connection string registers nothing and is not a " +
"valid configuration. Call AddConfigurationDatabase(connectionString) instead.",
error: true)]
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services)
{
// Defence-in-depth: even if a caller suppresses the compile-time obsolete error,
// fail fast at wire-up time rather than silently registering nothing and surfacing
// an opaque DI resolution failure much later.
throw new InvalidOperationException(
"AddConfigurationDatabase() requires a connection string. Call " +
"AddConfigurationDatabase(connectionString) with the configured " +
"'ScadaBridge:Database:ConfigurationDb' value.");
}
}