Recover integrations startup migrations and enum persistence
This commit is contained in:
@@ -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.
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user