Add Rider launch profiles, fix DI and migrations for dev startup

- Rider launch profiles: "ScadaLink Central" and "ScadaLink Site"
- appsettings.Central.json: correct test_infra credentials (ScadaLink_Dev1#,
  scadalink_app user, GLAuth on 3893, Mailpit on 1025)
- Fix HealthMonitoring DI: split site vs central registration to avoid
  missing IHealthReportTransport on central
- Regenerate single clean EF migration (InitialSchema) covering all entities
- Suppress PendingModelChangesWarning in dev mode
- Fix isDevelopment check for ASPNETCORE_ENVIRONMENT propagation

Verified: Host starts, connects to SQL Server, applies migrations, boots
Akka.NET cluster, LDAP auth works (admin/password via GLAuth), health
endpoint returns Healthy.
This commit is contained in:
Joseph Doherty
2026-03-17 03:01:21 -04:00
parent 2b2cc0a151
commit 121983fd66
11 changed files with 1721 additions and 1187 deletions

View File

@@ -1,28 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class SeedData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "LdapGroupMappings",
columns: new[] { "Id", "LdapGroupName", "Role" },
values: new object[] { 1, "SCADA-Admins", "Admin" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "LdapGroupMappings",
keyColumn: "Id",
keyValue: 1);
}
}
}

View File

@@ -12,8 +12,8 @@ using ScadaLink.ConfigurationDatabase;
namespace ScadaLink.ConfigurationDatabase.Migrations
{
[DbContext(typeof(ScadaLinkDbContext))]
[Migration("20260316231942_SeedData")]
partial class SeedData
[Migration("20260317065749_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -25,6 +25,25 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Xml")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
{
b.Property<int>("Id")
@@ -79,6 +98,44 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("AuditLogEntries");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConfigurationJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("DeployedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeploymentId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("InstanceId")
.HasColumnType("int");
b.Property<string>("RevisionHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("DeploymentId");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DeployedConfigSnapshots");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
{
b.Property<int>("Id")
@@ -103,6 +160,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ErrorMessage")
.HasColumnType("nvarchar(max)");
b.Property<int>("InstanceId")
.HasColumnType("int");
@@ -112,6 +172,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
@@ -954,6 +1015,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("TemplateScripts");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
.WithMany()
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace ScadaLink.ConfigurationDatabase.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -94,6 +94,20 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
table.PrimaryKey("PK_DataConnections", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ExternalSystemDefinitions",
columns: table => new
@@ -493,6 +507,29 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "DeployedConfigSnapshots",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
InstanceId = table.Column<int>(type: "int", nullable: false),
DeploymentId = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
RevisionHash = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
ConfigurationJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DeployedConfigSnapshots", x => x.Id);
table.ForeignKey(
name: "FK_DeployedConfigSnapshots_Instances_InstanceId",
column: x => x.InstanceId,
principalTable: "Instances",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DeploymentRecords",
columns: table => new
@@ -506,7 +543,8 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
DeployedBy = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
DeployedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
CompletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: true)
ErrorMessage = table.Column<string>(type: "nvarchar(max)", nullable: true),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
@@ -567,6 +605,11 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "LdapGroupMappings",
columns: new[] { "Id", "LdapGroupName", "Role" },
values: new object[] { 1, "SCADA-Admins", "Admin" });
migrationBuilder.CreateIndex(
name: "IX_ApiKeys_KeyValue",
table: "ApiKeys",
@@ -634,6 +677,17 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DeployedConfigSnapshots_DeploymentId",
table: "DeployedConfigSnapshots",
column: "DeploymentId");
migrationBuilder.CreateIndex(
name: "IX_DeployedConfigSnapshots_InstanceId",
table: "DeployedConfigSnapshots",
column: "InstanceId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DeploymentRecords_DeployedAt",
table: "DeploymentRecords",
@@ -813,6 +867,12 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
migrationBuilder.DropTable(
name: "DatabaseConnectionDefinitions");
migrationBuilder.DropTable(
name: "DataProtectionKeys");
migrationBuilder.DropTable(
name: "DeployedConfigSnapshots");
migrationBuilder.DropTable(
name: "DeploymentRecords");

View File

@@ -22,6 +22,25 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<string>("Xml")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Audit.AuditLogEntry", b =>
{
b.Property<int>("Id")
@@ -76,6 +95,44 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("AuditLogEntries");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ConfigurationJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("DeployedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DeploymentId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<int>("InstanceId")
.HasColumnType("int");
b.Property<string>("RevisionHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("DeploymentId");
b.HasIndex("InstanceId")
.IsUnique();
b.ToTable("DeployedConfigSnapshots");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
{
b.Property<int>("Id")
@@ -100,6 +157,9 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("ErrorMessage")
.HasColumnType("nvarchar(max)");
b.Property<int>("InstanceId")
.HasColumnType("int");
@@ -109,6 +169,7 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
@@ -951,6 +1012,15 @@ namespace ScadaLink.ConfigurationDatabase.Migrations
b.ToTable("TemplateScripts");
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeployedConfigSnapshot", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)
.WithMany()
.HasForeignKey("InstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ScadaLink.Commons.Entities.Deployment.DeploymentRecord", b =>
{
b.HasOne("ScadaLink.Commons.Entities.Instances.Instance", null)

View File

@@ -16,7 +16,9 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddConfigurationDatabase(this IServiceCollection services, string connectionString)
{
services.AddDbContext<ScadaLinkDbContext>(options =>
options.UseSqlServer(connectionString));
options.UseSqlServer(connectionString)
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();

View File

@@ -5,12 +5,23 @@ namespace ScadaLink.HealthMonitoring;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Register site-side health monitoring services.
/// Register site-side health monitoring services (metric collection + periodic reporting).
/// Call this on site nodes only. For central, call AddCentralHealthAggregation() instead.
/// </summary>
public static IServiceCollection AddSiteHealthMonitoring(this IServiceCollection services)
{
services.AddSingleton<ISiteHealthCollector, SiteHealthCollector>();
services.AddHostedService<HealthReportSender>();
return services;
}
/// <summary>
/// Register shared health monitoring services (safe for both central and site).
/// Does not start the HealthReportSender — call AddSiteHealthMonitoring() on site nodes for that.
/// </summary>
public static IServiceCollection AddHealthMonitoring(this IServiceCollection services)
{
services.AddSingleton<ISiteHealthCollector, SiteHealthCollector>();
services.AddHostedService<HealthReportSender>();
return services;
}

View File

@@ -65,6 +65,7 @@ try
builder.Services.AddClusterInfrastructure();
builder.Services.AddCommunication();
builder.Services.AddHealthMonitoring();
builder.Services.AddCentralHealthAggregation();
builder.Services.AddExternalSystemGateway();
builder.Services.AddNotificationService();
@@ -98,7 +99,8 @@ try
// Apply or validate database migrations (skip when running in test harness)
if (!string.Equals(configuration["ScadaLink:Database:SkipMigrations"], "true", StringComparison.OrdinalIgnoreCase))
{
var isDevelopment = app.Environment.IsDevelopment();
var isDevelopment = app.Environment.IsDevelopment()
|| string.Equals(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), "Development", StringComparison.OrdinalIgnoreCase);
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();

View File

@@ -0,0 +1,24 @@
{
"profiles": {
"ScadaLink Central": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "https://localhost:5001;http://localhost:5000",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Central",
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"ScadaLink Site": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Site",
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -2,13 +2,13 @@
"ScadaLink": {
"Node": {
"Role": "Central",
"NodeHostname": "central-node1",
"NodeHostname": "localhost",
"RemotingPort": 8081
},
"Cluster": {
"SeedNodes": [
"akka.tcp://scadalink@central-node1:8081",
"akka.tcp://scadalink@central-node2:8081"
"akka.tcp://scadalink@localhost:8081",
"akka.tcp://scadalink@localhost:8082"
],
"SplitBrainResolverStrategy": "keep-oldest",
"StableAfter": "00:00:15",
@@ -17,8 +17,8 @@
"MinNrOfMembers": 1
},
"Database": {
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLink_Config;User Id=sa;Password=YourPassword;TrustServerCertificate=True",
"MachineDataDb": "Server=localhost,1433;Database=ScadaLink_MachineData;User Id=sa;Password=YourPassword;TrustServerCertificate=True"
"ConfigurationDb": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true",
"MachineDataDb": "Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1#;TrustServerCertificate=true"
},
"Security": {
"LdapServer": "localhost",
@@ -26,7 +26,7 @@
"LdapUseTls": false,
"AllowInsecureLdap": true,
"LdapSearchBase": "dc=scadalink,dc=local",
"JwtSigningKey": "CHANGE-ME-development-signing-key-at-least-32-chars",
"JwtSigningKey": "scadalink-dev-jwt-signing-key-must-be-at-least-32-characters-long",
"JwtExpiryMinutes": 15,
"IdleTimeoutMinutes": 30
},
@@ -44,7 +44,12 @@
"InboundApi": {
"DefaultMethodTimeout": "00:00:30"
},
"Notification": {},
"Notification": {
"SmtpServer": "localhost",
"SmtpPort": 1025,
"AuthMode": "None",
"FromAddress": "scada-notifications@company.com"
},
"Logging": {
"MinimumLevel": "Information"
}

File diff suppressed because it is too large Load Diff