feat(configdb): add Deployment, NodeDeploymentState, ConfigEdit, DataProtectionKey entities

Phase 1 entities for the v2 live-edit + snapshot-deploy model:

  Deployment           — immutable artifact snapshot (replaces v1 ConfigGeneration row)
                         Status enum {Dispatching, AwaitingApplyAcks, Sealed,
                         PartiallyFailed, TimedOut}; carries the SHA256 RevisionHash and
                         the SnapshotAndFlatten() ArtifactBlob; RowVersion for optimistic
                         concurrency.
  NodeDeploymentState  — per-(node, deployment) apply progress row owned by
                         DriverHostActor (replaces single-row ClusterNodeGenerationState).
                         Composite key (NodeId, DeploymentId) gives the
                         ConfigPublishCoordinator the full history it needs to
                         reconstruct in-flight state after a failover.
  ConfigEdit           — append-only audit row written by AdminOperationsActor on every
                         mutating op; optional ExecutionId correlates edits inside one
                         admin transaction (e.g. an import batch).
  DataProtectionKey    — ASP.NET DataProtection key ring storage via
                         IDataProtectionKeyContext so every admin-role node decrypts
                         the same cookies without sharing a filesystem.

OtOpcUaConfigDbContext now implements IDataProtectionKeyContext and registers four new
DbSets + four new ConfigureXxx mappings.

Central package bumps (forced by Microsoft.AspNetCore.DataProtection.EntityFrameworkCore
10.0.7's transitive dep):

  Microsoft.EntityFrameworkCore.{,Design,InMemory,SqlServer}  10.0.0 -> 10.0.7
  Microsoft.Extensions.{Configuration.Abstractions,Configuration.Json,Hosting,Hosting.WindowsServices,Http}  10.0.0 -> 10.0.7

EF migration generation + the ConfigGeneration drop + RedundancyRole column removal are
deferred to Task 14 (high-risk, non-parallelizable).
This commit is contained in:
Joseph Doherty
2026-05-26 03:49:59 -04:00
parent 30a2104fa5
commit 8e2c4f2835
8 changed files with 214 additions and 10 deletions

View File

@@ -54,18 +54,18 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.0.0" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="6.1.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />

View File

@@ -0,0 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Append-only audit row written by AdminOperationsActor on every mutating live-edit
/// operation. The ExecutionId optionally correlates a sequence of edits that ran inside one
/// admin transaction (e.g. an import batch that updates many Equipment rows).
/// </summary>
public sealed class ConfigEdit
{
public Guid EditId { get; init; } = Guid.NewGuid();
public required string EntityType { get; init; }
public Guid EntityId { get; init; }
/// <summary>JSON payload of the column-name → new-value pairs touched by this edit.</summary>
public required string FieldsJson { get; init; }
/// <summary>Optional correlation across edits inside a single admin operation.</summary>
public Guid? ExecutionId { get; init; }
public required string EditedBy { get; init; }
public DateTime EditedAtUtc { get; init; } = DateTime.UtcNow;
public required string SourceNode { get; init; }
}

View File

@@ -0,0 +1,30 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Immutable snapshot of a config artifact dispatched to every driver-role node by the
/// ConfigPublishCoordinator. Replaces the v1 <see cref="ConfigGeneration"/> draft/publish
/// row; the ArtifactBlob carries the SnapshotAndFlatten() output produced by
/// AdminOperationsActor.
/// </summary>
public sealed class Deployment
{
public Guid DeploymentId { get; init; } = Guid.NewGuid();
public required string RevisionHash { get; init; }
public DeploymentStatus Status { get; set; } = DeploymentStatus.Dispatching;
public required string CreatedBy { get; init; }
public DateTime CreatedAtUtc { get; init; } = DateTime.UtcNow;
public byte[] ArtifactBlob { get; init; } = Array.Empty<byte>();
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public string? FailureReason { get; set; }
public DateTime? SealedAtUtc { get; set; }
}

View File

@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Per-(node, deployment) apply progress row owned by the DriverHostActor. Replaces the
/// v1 <see cref="ClusterNodeGenerationState"/> single-row-per-node model with a history
/// of every apply attempt so the ConfigPublishCoordinator can reconstruct in-flight state
/// after a failover.
/// </summary>
public sealed class NodeDeploymentState
{
public required string NodeId { get; init; }
public Guid DeploymentId { get; init; }
public NodeDeploymentStatus Status { get; set; } = NodeDeploymentStatus.Applying;
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime? AppliedAtUtc { get; set; }
public string? FailureReason { get; set; }
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
public ClusterNode? Node { get; set; }
public Deployment? Deployment { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Lifecycle of a deployment artifact dispatched by the v2 ConfigPublishCoordinator.
/// Replaces the v1 ConfigGeneration draft/publish lifecycle (decision tracked in the
/// v2 hosting-alignment design doc).
/// </summary>
public enum DeploymentStatus
{
Dispatching = 0,
AwaitingApplyAcks = 1,
Sealed = 2,
PartiallyFailed = 3,
TimedOut = 4,
}

View File

@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Per-node deployment apply state. Replaces the v1 NodeApplyStatus that was attached to
/// ClusterNodeGenerationState.
/// </summary>
public enum NodeDeploymentStatus
{
Applying = 0,
Applied = 1,
Failed = 2,
}

View File

@@ -1,3 +1,4 @@
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
@@ -9,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration;
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
/// </summary>
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
: DbContext(options)
: DbContext(options), IDataProtectionKeyContext
{
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
@@ -37,6 +38,16 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
// v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment). Replace the
// ConfigGeneration/ClusterNodeGenerationState pair when Task 14's migration runs.
public DbSet<Deployment> Deployments => Set<Deployment>();
public DbSet<NodeDeploymentState> NodeDeploymentStates => Set<NodeDeploymentState>();
public DbSet<ConfigEdit> ConfigEdits => Set<ConfigEdit>();
// ASP.NET DataProtection key ring storage (decision: keys persisted in ConfigDb so every
// admin-role node decrypts the same cookies without sharing a filesystem).
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -64,6 +75,10 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
ConfigureScriptedAlarmState(modelBuilder);
ConfigureDeployment(modelBuilder);
ConfigureNodeDeploymentState(modelBuilder);
ConfigureConfigEdit(modelBuilder);
ConfigureDataProtectionKey(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
@@ -729,4 +744,79 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
});
}
private static void ConfigureDeployment(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Deployment>(e =>
{
e.ToTable("Deployment");
e.HasKey(x => x.DeploymentId);
e.Property(x => x.DeploymentId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.RevisionHash).HasMaxLength(64).IsRequired();
e.Property(x => x.Status).HasConversion<int>();
e.Property(x => x.CreatedBy).HasMaxLength(128).IsRequired();
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.ArtifactBlob).HasColumnType("varbinary(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.Property(x => x.FailureReason).HasMaxLength(2048);
e.Property(x => x.SealedAtUtc).HasColumnType("datetime2(3)");
e.HasIndex(x => x.Status).HasDatabaseName("IX_Deployment_Status");
e.HasIndex(x => x.CreatedAtUtc).HasDatabaseName("IX_Deployment_CreatedAt");
});
}
private static void ConfigureNodeDeploymentState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NodeDeploymentState>(e =>
{
e.ToTable("NodeDeploymentState");
e.HasKey(x => new { x.NodeId, x.DeploymentId });
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.Status).HasConversion<int>();
e.Property(x => x.StartedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.AppliedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FailureReason).HasMaxLength(2048);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Node).WithMany().HasForeignKey(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Deployment).WithMany().HasForeignKey(x => x.DeploymentId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.DeploymentId).HasDatabaseName("IX_NodeDeploymentState_Deployment");
e.HasIndex(x => x.Status).HasDatabaseName("IX_NodeDeploymentState_Status");
});
}
private static void ConfigureConfigEdit(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ConfigEdit>(e =>
{
e.ToTable("ConfigEdit", t =>
{
t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1");
});
e.HasKey(x => x.EditId);
e.Property(x => x.EditId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EntityType).HasMaxLength(64).IsRequired();
e.Property(x => x.FieldsJson).HasColumnType("nvarchar(max)").IsRequired();
e.Property(x => x.EditedBy).HasMaxLength(128).IsRequired();
e.Property(x => x.EditedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.SourceNode).HasMaxLength(64).IsRequired();
// Replays of admin operations group rows by ExecutionId, then by time.
e.HasIndex(x => new { x.EntityType, x.EntityId }).HasDatabaseName("IX_ConfigEdit_Entity");
e.HasIndex(x => x.ExecutionId).HasFilter("[ExecutionId] IS NOT NULL").HasDatabaseName("IX_ConfigEdit_Execution");
e.HasIndex(x => x.EditedAtUtc).HasDatabaseName("IX_ConfigEdit_EditedAt");
});
}
private static void ConfigureDataProtectionKey(ModelBuilder modelBuilder)
{
// ASP.NET DataProtection ships its own EF mapping; override only the table name so it
// matches the rest of the schema's PascalCase convention.
modelBuilder.Entity<DataProtectionKey>(e =>
{
e.ToTable("DataProtectionKeys");
});
}
}

View File

@@ -18,6 +18,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions"/>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"/>
<PackageReference Include="LiteDB"/>