From b7cfdbd5537041561d35f14c404a566f7c36938a Mon Sep 17 00:00:00 2001 From: master <> Date: Sat, 7 Mar 2026 02:45:40 +0200 Subject: [PATCH] Recover integrations startup migrations and enum persistence --- ...rations_live_startup_migration_recovery.md | 80 +++++++++++++++++++ .../Program.cs | 28 +++---- .../StellaOps.Integrations.WebService.csproj | 1 + .../IntegrationDbContextModelBuilder.cs | 2 +- .../IntegrationEntityEntityType.cs | 24 ++++-- .../IntegrationDbContext.cs | 8 +- .../IntegrationPersistenceModelTests.cs | 61 ++++++++++++++ 7 files changed, 176 insertions(+), 28 deletions(-) create mode 100644 docs/implplan/SPRINT_20260307_007_Integrations_live_startup_migration_recovery.md create mode 100644 src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPersistenceModelTests.cs diff --git a/docs/implplan/SPRINT_20260307_007_Integrations_live_startup_migration_recovery.md b/docs/implplan/SPRINT_20260307_007_Integrations_live_startup_migration_recovery.md new file mode 100644 index 000000000..4daabbf96 --- /dev/null +++ b/docs/implplan/SPRINT_20260307_007_Integrations_live_startup_migration_recovery.md @@ -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. diff --git a/src/Integrations/StellaOps.Integrations.WebService/Program.cs b/src/Integrations/StellaOps.Integrations.WebService/Program.cs index f36b0a2e6..a12dc9e8b 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/Program.cs +++ b/src/Integrations/StellaOps.Integrations.WebService/Program.cs @@ -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(options => +{ + options.ConnectionString = connectionString; + options.SchemaName = IntegrationDbContext.DefaultSchemaName; +}); + builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); +builder.Services.AddStartupMigrations( + IntegrationDbContext.DefaultSchemaName, + "Integrations.Persistence", + typeof(IntegrationDbContext).Assembly); + // Repository builder.Services.AddScoped(); @@ -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(); - 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()) { diff --git a/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj b/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj index 32b5ea5c3..b49e257dc 100644 --- a/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj +++ b/src/Integrations/StellaOps.Integrations.WebService/StellaOps.Integrations.WebService.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationDbContextModelBuilder.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationDbContextModelBuilder.cs index 3fc3c9bc3..38ae32cfa 100644 --- a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationDbContextModelBuilder.cs +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationDbContextModelBuilder.cs @@ -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) { } diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationEntityEntityType.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationEntityEntityType.cs index 4b1123483..606d35e91 100644 --- a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationEntityEntityType.cs +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/EfCore/CompiledModels/IntegrationEntityEntityType.cs @@ -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("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - lastHealthStatus.SetSentinelFromProviderValue(0); + fieldInfo: typeof(IntegrationEntity).GetField("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("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - provider.SetSentinelFromProviderValue(0); + fieldInfo: typeof(IntegrationEntity).GetField("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("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - status.SetSentinelFromProviderValue(0); + fieldInfo: typeof(IntegrationEntity).GetField("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("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)); - type.SetSentinelFromProviderValue(0); + fieldInfo: typeof(IntegrationEntity).GetField("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", diff --git a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs index d744f25ec..45d4fd6af 100644 --- a/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs +++ b/src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs @@ -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().HasColumnType("text").HasColumnName("type").IsRequired(); + entity.Property(e => e.Provider).HasConversion().HasColumnType("text").HasColumnName("provider").IsRequired(); + entity.Property(e => e.Status).HasConversion().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().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(); diff --git a/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPersistenceModelTests.cs b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPersistenceModelTests.cs new file mode 100644 index 000000000..a03e7836d --- /dev/null +++ b/src/Integrations/__Tests/StellaOps.Integrations.Tests/IntegrationPersistenceModelTests.cs @@ -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() + .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"); + } +}