Recover integrations startup migrations and enum persistence

This commit is contained in:
master
2026-03-07 02:45:40 +02:00
parent 803940bd36
commit b7cfdbd553
7 changed files with 176 additions and 28 deletions

View File

@@ -0,0 +1,80 @@
# Sprint 20260307-007 - Integrations Live Startup Migration Recovery
## Topic & Scope
- Restore the live Integrations API used by Setup/Ops integration pages after Playwright QA exposed persistent `500` failures on `/api/v1/integrations*`.
- Replace the fragile startup schema bootstrap with the repo-standard startup migration host so fresh databases and reset volumes converge automatically.
- Verify the recovered service from the real Web UI on `https://stella-ops.local`, then hand the result back into the active Web QA loop.
- Working directory: `src/Integrations`.
- Expected evidence: targeted service build/restart, container log evidence, and live Playwright route checks for Setup integration pages.
## Dependencies & Concurrency
- Upstream Web QA evidence is tracked in `docs/implplan/SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md`.
- Safe parallelism: this sprint is limited to `src/Integrations` plus sprint documentation. Do not edit unrelated Web files from this sprint.
- Runtime dependency: `stellaops-integrations-web` must be rebuildable independently from the rest of the compose stack.
## Documentation Prerequisites
- `docs/modules/ui/architecture.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `src/Integrations/AGENTS.md`
## Delivery Tracker
### INT-MIG-001 - Reproduce live integrations API failure
Status: DONE
Dependency: none
Owners: QA, Developer
Task description:
- Use authenticated Playwright against `https://stella-ops.local` to reproduce the failing Setup integrations list/detail routes.
- Capture the live API behavior and the matching container exception before changing service code.
Completion criteria:
- [x] Live reproduction exists for `/setup/integrations/secrets` and the underlying `/api/v1/integrations*` failure.
- [x] The service-side exception is captured with file/line evidence.
### INT-MIG-002 - Restore startup migrations for integrations schema
Status: DONE
Dependency: INT-MIG-001
Owners: Developer
Task description:
- Wire `AddStartupMigrations(...)` into the Integrations service using the persistence assembly that already embeds SQL migrations.
- Remove the ineffective schema bootstrap path so fresh installs rely on the canonical migration host.
Completion criteria:
- [x] Startup migrations are registered from the Integrations persistence assembly.
- [x] The service no longer relies on `EnsureCreatedAsync()` for schema convergence.
- [x] Service builds successfully with the new migration wiring.
### INT-MIG-003 - Rebuild, retest, and hand back to Web QA
Status: DONE
Dependency: INT-MIG-002
Owners: QA, Developer
Task description:
- Rebuild only the integrations service, restart it in the running compose stack, and confirm the missing table failure is gone.
- Replay the affected Setup integration routes in the real UI and record the post-fix state for the Web sprint.
Completion criteria:
- [x] `stellaops-integrations-web` restarts with successful startup migration logs.
- [x] Live Setup integration pages no longer fail because `integrations.integrations` is missing.
- [x] Findings are logged back into the Web sprint for continued route/action QA.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created after Playwright QA on `https://stella-ops.local` proved Setup integration routes were failing outside Web scope. Live repro showed `/setup/integrations/secrets` and `/setup/integrations/int-secret-1` stuck on loading states while `stellaops-integrations-web` logged `42P01 relation "integrations.integrations" does not exist` from `PostgresIntegrationRepository` / `IntegrationService.ListAsync`. | QA |
| 2026-03-07 | Wired `AddStartupMigrations(...)` into `StellaOps.Integrations.WebService`, removed the host-level `EnsureCreatedAsync()` bootstrap, built the service, rebuilt `stellaops/integrations-web:dev`, and recreated only `stellaops-integrations-web`. Startup logs now show `Migration: Applied 1 migration(s) for Integrations.Persistence` on the first rebuilt container and `Database is up to date` on subsequent restarts. | Developer |
| 2026-03-07 | A second live failure surfaced immediately after the schema fix: authenticated Playwright still hit `500` with `42883: operator does not exist: text = integer` on `/api/v1/integrations`. Root cause was enum-backed `type/provider/status/last_health_status` EF mappings still comparing integer values against `text` columns. | QA |
| 2026-03-07 | Fixed the persistence model to store all enum-backed columns as `text`, regenerated the EF compiled model, and added focused persistence-model guard tests in `src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPersistenceModelTests.cs`. Targeted xUnit-v3 runner verification passed (`Total: 6, Errors: 0, Failed: 0, Skipped: 0`). | Developer |
| 2026-03-07 | Live retest against the rebuilt service confirmed the Integrations host now returns immediate `200` responses directly for `type=1`, `type=4`, and `type=5` queries when called on `integrations.stella-ops.local` with tenant context. Remaining spinner behavior was handed back to the Web sprint because the direct service was healthy and the stall only persisted on the authenticated `stella-ops.local` path. | QA |
## Decisions & Risks
- Decision: this sprint is limited to the Integrations service startup/migration failure and the minimum documentation needed to hand results back to Web QA.
- Decision: the proper fix must align with the repo-wide auto-migration requirement instead of patching databases manually.
- Decision: enum-backed persistence columns must use explicit string conversions because the canonical migration schema stores `type`, `provider`, `status`, and `last_health_status` as `text`.
- Risk: other agents may be editing unrelated Web sprint files in parallel.
- Mitigation: keep this sprint inside `src/Integrations` and record only cross-sprint evidence handoff.
- Risk: the authenticated `https://stella-ops.local/api/v1/integrations*` path can still stall even though the Integrations host answers immediately on its direct service alias.
- Mitigation: treat that as a frontdoor/Web-path defect and continue triage in `SPRINT_20260306_003_FE_playwright_setup_reset_iteration_loop.md`; do not expand this sprint into gateway/frontdoor changes.
## Next Checkpoints
- 2026-03-07: Land startup migration fix and rebuild only `stellaops-integrations-web`.
- 2026-03-07: Re-run live Playwright checks on Setup integration routes after the service restart.

View File

@@ -11,6 +11,8 @@ using StellaOps.Integrations.WebService.Infrastructure;
using StellaOps.Integrations.WebService.Security;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Infrastructure.Postgres.Options;
using StellaOps.Localization;
using StellaOps.Router.AspNet;
var builder = WebApplication.CreateBuilder(args);
@@ -27,9 +29,20 @@ var connectionString = builder.Configuration.GetConnectionString("IntegrationsDb
?? builder.Configuration.GetConnectionString("Default")
?? "Host=localhost;Database=stellaops_integrations;Username=postgres;Password=postgres";
builder.Services.Configure<PostgresOptions>(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = IntegrationDbContext.DefaultSchemaName;
});
builder.Services.AddDbContext<IntegrationDbContext>(options =>
options.UseNpgsql(connectionString));
builder.Services.AddStartupMigrations(
IntegrationDbContext.DefaultSchemaName,
"Integrations.Persistence",
typeof(IntegrationDbContext).Assembly);
// Repository
builder.Services.AddScoped<IIntegrationRepository, PostgresIntegrationRepository>();
@@ -96,21 +109,6 @@ builder.TryAddStellaOpsLocalBinding("integrations");
var app = builder.Build();
app.LogStellaOpsLocalHostname("integrations");
// Auto-migrate: ensure integrations schema and tables exist on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<IntegrationDbContext>();
try
{
await db.Database.EnsureCreatedAsync();
app.Logger.LogInformation("Integrations database schema ensured");
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Integrations database EnsureCreated failed (may already exist)");
}
}
// Configure pipeline
if (app.Environment.IsDevelopment())
{

View File

@@ -18,6 +18,7 @@
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.Harbor\StellaOps.Integrations.Plugin.Harbor.csproj" />
<ProjectReference Include="..\__Plugins\StellaOps.Integrations.Plugin.InMemory\StellaOps.Integrations.Plugin.InMemory.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Plugin\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />

View File

@@ -12,7 +12,7 @@ namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
public partial class IntegrationDbContextModel
{
private IntegrationDbContextModel()
: base(skipDetectChanges: false, modelId: new Guid("a127631f-ea21-49e7-ba49-1169d76b0980"), entityTypeCount: 1)
: base(skipDetectChanges: false, modelId: new Guid("2bdd4ca7-26c5-4648-ad34-9d64426b7528"), entityTypeCount: 1)
{
}

View File

@@ -115,10 +115,12 @@ namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
"LastHealthStatus",
typeof(HealthStatus),
propertyInfo: typeof(IntegrationEntity).GetProperty("LastHealthStatus", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<LastHealthStatus>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
lastHealthStatus.SetSentinelFromProviderValue(0);
fieldInfo: typeof(IntegrationEntity).GetField("<LastHealthStatus>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
providerPropertyType: typeof(string));
lastHealthStatus.SetSentinelFromProviderValue("Unknown");
lastHealthStatus.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
lastHealthStatus.AddAnnotation("Relational:ColumnName", "last_health_status");
lastHealthStatus.AddAnnotation("Relational:ColumnType", "text");
var name = runtimeEntityType.AddProperty(
"Name",
@@ -143,19 +145,23 @@ namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
"Provider",
typeof(IntegrationProvider),
propertyInfo: typeof(IntegrationEntity).GetProperty("Provider", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Provider>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
provider.SetSentinelFromProviderValue(0);
fieldInfo: typeof(IntegrationEntity).GetField("<Provider>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
providerPropertyType: typeof(string));
provider.SetSentinelFromProviderValue("0");
provider.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
provider.AddAnnotation("Relational:ColumnName", "provider");
provider.AddAnnotation("Relational:ColumnType", "text");
var status = runtimeEntityType.AddProperty(
"Status",
typeof(IntegrationStatus),
propertyInfo: typeof(IntegrationEntity).GetProperty("Status", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
status.SetSentinelFromProviderValue(0);
fieldInfo: typeof(IntegrationEntity).GetField("<Status>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
providerPropertyType: typeof(string));
status.SetSentinelFromProviderValue("Pending");
status.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
status.AddAnnotation("Relational:ColumnName", "status");
status.AddAnnotation("Relational:ColumnType", "text");
var tagsJson = runtimeEntityType.AddProperty(
"TagsJson",
@@ -181,10 +187,12 @@ namespace StellaOps.Integrations.Persistence.EfCore.CompiledModels
"Type",
typeof(IntegrationType),
propertyInfo: typeof(IntegrationEntity).GetProperty("Type", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(IntegrationEntity).GetField("<Type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly));
type.SetSentinelFromProviderValue(0);
fieldInfo: typeof(IntegrationEntity).GetField("<Type>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
providerPropertyType: typeof(string));
type.SetSentinelFromProviderValue("0");
type.AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.None);
type.AddAnnotation("Relational:ColumnName", "type");
type.AddAnnotation("Relational:ColumnType", "text");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",

View File

@@ -37,16 +37,16 @@ public partial class IntegrationDbContext : DbContext
entity.Property(e => e.Name).HasColumnName("name").HasMaxLength(256).IsRequired();
entity.Property(e => e.Description).HasColumnName("description").HasMaxLength(1024);
entity.Property(e => e.Type).HasColumnName("type").IsRequired();
entity.Property(e => e.Provider).HasColumnName("provider").IsRequired();
entity.Property(e => e.Status).HasColumnName("status").IsRequired();
entity.Property(e => e.Type).HasConversion<string>().HasColumnType("text").HasColumnName("type").IsRequired();
entity.Property(e => e.Provider).HasConversion<string>().HasColumnType("text").HasColumnName("provider").IsRequired();
entity.Property(e => e.Status).HasConversion<string>().HasColumnType("text").HasColumnName("status").IsRequired();
entity.Property(e => e.Endpoint).HasColumnName("endpoint").HasMaxLength(2048).IsRequired();
entity.Property(e => e.AuthRefUri).HasColumnName("auth_ref_uri").HasMaxLength(1024);
entity.Property(e => e.OrganizationId).HasColumnName("organization_id").HasMaxLength(256);
entity.Property(e => e.ConfigJson).HasColumnName("config_json").HasColumnType("jsonb");
entity.Property(e => e.LastHealthStatus).HasColumnName("last_health_status");
entity.Property(e => e.LastHealthStatus).HasConversion<string>().HasColumnType("text").HasColumnName("last_health_status");
entity.Property(e => e.LastHealthCheckAt).HasColumnName("last_health_check_at");
entity.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired();

View File

@@ -0,0 +1,61 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using StellaOps.Integrations.Persistence;
using StellaOps.Integrations.Persistence.EfCore.CompiledModels;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Integrations.Tests;
public sealed class IntegrationPersistenceModelTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RuntimeModel_StoresEnumColumnsAsText()
{
var options = new DbContextOptionsBuilder<IntegrationDbContext>()
.UseNpgsql("Host=localhost;Database=stellaops_test;Username=postgres;Password=postgres")
.Options;
using var context = new IntegrationDbContext(options);
var entityType = context.Model.FindEntityType(typeof(IntegrationEntity));
entityType.Should().NotBeNull();
AssertTextBackedEnum(entityType!, nameof(IntegrationEntity.Type));
AssertTextBackedEnum(entityType!, nameof(IntegrationEntity.Provider));
AssertTextBackedEnum(entityType!, nameof(IntegrationEntity.Status));
AssertTextBackedEnum(entityType!, nameof(IntegrationEntity.LastHealthStatus));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompiledModel_StoresEnumColumnsAsText()
{
var entityType = IntegrationDbContextModel.Instance.FindEntityType(typeof(IntegrationEntity));
entityType.Should().NotBeNull();
AssertTextColumn(entityType!, nameof(IntegrationEntity.Type));
AssertTextColumn(entityType!, nameof(IntegrationEntity.Provider));
AssertTextColumn(entityType!, nameof(IntegrationEntity.Status));
AssertTextColumn(entityType!, nameof(IntegrationEntity.LastHealthStatus));
}
private static void AssertTextBackedEnum(IReadOnlyEntityType entityType, string propertyName)
{
var property = entityType.FindProperty(propertyName);
property.Should().NotBeNull($"{propertyName} must be mapped in the integration persistence model");
property!.GetColumnType().Should().Be("text");
property.GetTypeMapping().Converter.Should().NotBeNull($"{propertyName} must serialize enum values as text");
property.GetTypeMapping().Converter!.ProviderClrType.Should().Be(typeof(string));
}
private static void AssertTextColumn(IReadOnlyEntityType entityType, string propertyName)
{
var property = entityType.FindProperty(propertyName);
property.Should().NotBeNull($"{propertyName} must be present in the compiled model");
property!.FindAnnotation("Relational:ColumnType")?.Value.Should().Be("text");
}
}