diff --git a/docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md b/docs-archived/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md similarity index 100% rename from docs/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md rename to docs-archived/implplan/SPRINT_20260408_001_FE_crypto_provider_picker.md diff --git a/docs/implplan/SPRINT_20260408_004_Platform_db_schema_violations_cleanup.md b/docs-archived/implplan/SPRINT_20260408_004_Platform_db_schema_violations_cleanup.md similarity index 100% rename from docs/implplan/SPRINT_20260408_004_Platform_db_schema_violations_cleanup.md rename to docs-archived/implplan/SPRINT_20260408_004_Platform_db_schema_violations_cleanup.md diff --git a/docs/implplan/SPRINT_20260409_001_Platform_local_container_rebuild_integrations_sources.md b/docs-archived/implplan/SPRINT_20260409_001_Platform_local_container_rebuild_integrations_sources.md similarity index 100% rename from docs/implplan/SPRINT_20260409_001_Platform_local_container_rebuild_integrations_sources.md rename to docs-archived/implplan/SPRINT_20260409_001_Platform_local_container_rebuild_integrations_sources.md diff --git a/docs-archived/implplan/SPRINT_20260413_001_Platform_scratch_setup_local_integrations.md b/docs-archived/implplan/SPRINT_20260413_001_Platform_scratch_setup_local_integrations.md new file mode 100644 index 000000000..72e105a03 --- /dev/null +++ b/docs-archived/implplan/SPRINT_20260413_001_Platform_scratch_setup_local_integrations.md @@ -0,0 +1,119 @@ +# Sprint 20260413-001 - Scratch Setup With Local Integrations + +## Topic & Scope +- Rebuild the Stella Ops local environment from Stella-owned runtime state only and prove the documented scratch setup path still converges on this machine. +- Bring up the local third-party integration lanes that the repo supports for real local validation, including the optional heavier providers when they are practical to run locally. +- Fix only the concrete bootstrap and setup blockers exposed by the scratch run, then sync the setup docs and sprint log with the actual working commands and residual risks. +- Working directory: `.`. +- Expected evidence: Stella-only cleanup and bootstrap commands, compose health/status output, local integration verification results, and updated setup/docs references for any changed behavior. + +## Dependencies & Concurrency +- Required docs: `docs/INSTALL_GUIDE.md`, `docs/dev/DEV_ENVIRONMENT_SETUP.md`, `devops/compose/README.md`, `docs/integrations/LOCAL_SERVICES.md`, `docs/modules/platform/architecture-overview.md`, `docs/modules/integrations/architecture.md`. +- This sprint executes sequentially because the scratch setup and integration registration depend on a single mutable local Docker environment. +- Cross-module edits are allowed only for setup blockers found during the scratch run, scoped to `scripts/**`, `devops/**`, `docs/**`, `src/Router/**`, `src/Authority/**`, `src/Platform/**`, `src/ReleaseOrchestrator/**`, `src/Integrations/**`, `src/Concelier/**`, `src/Findings/**`, `src/Timeline/**`, `src/Graph/**`, and `src/JobEngine/**`. + +## Documentation Prerequisites +- `docs/INSTALL_GUIDE.md` +- `docs/dev/DEV_ENVIRONMENT_SETUP.md` +- `devops/compose/README.md` +- `docs/integrations/LOCAL_SERVICES.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/modules/integrations/architecture.md` + +## Delivery Tracker + +### SETUP-001 - Rebuild Stella local stack from scratch on this machine +Status: DONE +Dependency: none +Owners: Developer / Ops Integrator +Task description: +- Remove Stella-owned local Docker runtime state needed for a clean bootstrap, then run the repo-supported scratch setup path on this workstation. +- Treat the documented scripts and compose files as the authority. If a blocker appears, capture the exact failure, apply the smallest correct fix, and rerun until the core stack is usable or a real blocker remains. + +Completion criteria: +- [x] Stella-owned Docker runtime state is reset without touching unrelated machine resources. +- [x] The repo-supported setup path is executed from a clean starting point. +- [x] Core stack health, frontdoor reachability, and bootstrap readiness are captured with concrete evidence. +- [x] Any setup blocker is either fixed or recorded with exact failure details. + +### SETUP-002 - Bring up and verify the local integrations lane +Status: DONE +Dependency: SETUP-001 +Owners: Developer / Ops Integrator +Task description: +- Start the supported local third-party integration compose lanes and verify the providers that Stella Ops can actually exercise in this environment. +- Include the deterministic QA fixtures and the real-provider compose lane, enabling optional heavier profiles only when they can be run locally and verified. + +Completion criteria: +- [x] QA integration fixtures are started and checked when needed for success-path validation. +- [x] The real local provider lane is running for the supported providers on this machine. +- [x] Reachable providers are verified with concrete API/health evidence. +- [x] Any provider left disabled or degraded is recorded with the exact reason. + +### SETUP-003 - Sync setup docs and sprint evidence with the proven path +Status: DONE +Dependency: SETUP-002 +Owners: Developer / Documentation Author +Task description: +- Update the installation, environment, or integration docs only where the live scratch run proves the documented path is incomplete or wrong. +- Record the final working command sequence, deviations, and remaining risks in this sprint so the setup is auditable and repeatable. + +Completion criteria: +- [x] Setup and integration docs match the validated local path. +- [x] Decisions, deviations, and remaining risks are logged with linked docs. +- [x] The sprint execution log captures the scratch run, fixes, and final verification outcome. + +### SETUP-004 - Converge fresh-volume core services to ready state +Status: DONE +Dependency: SETUP-003 +Owners: Developer / Ops Integrator +Task description: +- Repair the remaining startup blockers in Findings Ledger, Timeline, and Graph so a fresh local PostgreSQL volume converges without manual SQL, CLI migration commands, or degraded background services. +- Keep fixes scoped to the concrete startup path: migration wiring, migration categorization/idempotency, and hosted-service lifecycle defects required for `https://stella-ops.local/healthz` to return `ready=true`. + +Completion criteria: +- [x] `stellaops-findings-ledger-web` starts cleanly after auto-applying the `findings` schema migrations, including `ledger_projection_offsets`. +- [x] `stellaops-timeline-web` starts cleanly on a fresh database without pending manual release migrations blocking startup. +- [x] `stellaops-graph-api` remains healthy without restart loops from `GraphChangeStreamProcessor`. +- [x] Frontdoor health returns `ready=true` with no missing required microservices. + +### SETUP-005 - Make the full local integrations path repeatable +Status: DONE +Dependency: SETUP-004 +Owners: Developer / Documentation Author +Task description: +- Close the gap between the current successful local state and a repeatable scratch path by automating or documenting the remaining GitLab/bootstrap work needed for all local-ready integrations, including GitLab SCM, CI, and registry. +- Reverify the final local integration catalog and advisory-source health after the core platform is green so the sprint closes on the actual end-state. + +Completion criteria: +- [x] The scratch path can provision all supported local integrations, including GitLab-backed entries, without undocumented manual steps. +- [x] The local integration catalog verifies healthy for every supported local provider on tenant `demo-prod`. +- [x] Setup docs and sprint evidence reflect the final converged path and any remaining non-local upstream risks. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-04-13 | Sprint created for a fresh Stella-only local bootstrap plus local integrations bring-up and verification on this machine. | Developer | +| 2026-04-13 | Reset Stella-owned compose state, rebuilt only the missing local images, restored the core stack, and confirmed frontdoor readiness at `https://stella-ops.local` plus router health at `/healthz`. | Developer | +| 2026-04-13 | Started QA fixtures plus the local third-party provider lane (`gitea`, `jenkins`, `nexus`, `vault`, `docker-registry`, `minio`, `consul`, `gitlab`, `runtime-host-fixture`) and verified the container health set. | Developer | +| 2026-04-13 | Added `scripts/register-local-integrations.ps1`, converged `demo-prod` to 13/13 healthy local-ready integration entries, and recorded the optional GitLab/PAT caveat in `docs/INSTALL_GUIDE.md`, `devops/compose/README.md`, and `docs/integrations/LOCAL_SERVICES.md`. | Developer | +| 2026-04-13 | Generated a local GitLab PAT through the live GitLab UI, stored `authref://vault/gitlab#access-token` plus `authref://vault/gitlab#registry-basic` in the dev Vault, re-enabled the GitLab registry surface, and converged `demo-prod` to 16/16 healthy local integration entries including GitLab Server, GitLab CI, and GitLab Container Registry. | Developer | +| 2026-04-13 | Fixed fresh-volume startup blockers across Findings, Timeline, Graph, and shared Postgres migration infrastructure: explicit-transaction startup migrations now run without an outer transaction, Graph startup now applies both persistence and API schema migrations, wildcard local URL bindings are normalized for Kestrel, and Findings rollback SQL remains operator-only instead of embedded as a forward startup migration. | Developer | +| 2026-04-13 | Rebuilt and redeployed `findings-ledger-web`, `timeline-web`, `graph-api`, and `router-gateway`; verified all four containers healthy and confirmed `https://stella-ops.local/healthz` returns `ready=true` with no missing required microservices. | Developer | +| 2026-04-13 | Revalidated the tenant integration catalog after core recovery: `demo-prod` reports 16/16 configured local integrations healthy (Consul, Docker Registry, eBPF runtime host, Gitea, GitHub App fixture, GitLab CI, GitLab Container Registry, GitLab Server, Harbor fixture, Jenkins, MinIO, Nexus Registry, NVD Mirror, OSV Mirror, StellaOps Mirror, Vault). | Developer | +| 2026-04-13 | Re-ran `powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 -Tenant demo-prod -IncludeGitLab -IncludeGitLabRegistry`; all 16 local integrations converged as `existing` and revalidated healthy, confirming the helper is idempotent on the green stack. | Developer | +| 2026-04-13 | Re-ran `POST http://127.1.0.9/api/v1/advisory-sources/check` with tenant `demo-prod`; result: 74/74 healthy, 0 failed, all enabled. | Developer | + +## Decisions & Risks +- Decision: destructive operations in this sprint are limited to Stella-owned Docker compose containers, volumes, and networks required for the local scratch bootstrap. +- Risk: the repo is already in a dirty working tree, including active Concelier and Web changes outside this sprint. Those edits will not be reverted and may influence the observed scratch-setup behavior. +- Decision: the repeatable local-registration default is the 13 turnkey providers that pass without extra secret material on a fresh machine: Harbor fixture, Docker Registry, Nexus, GitHub App fixture, Gitea, Jenkins, Vault, Consul, eBPF runtime-host fixture, MinIO, and the three Concelier-backed feed mirror providers. +- Decision: GitLab Server, GitLab CI, and GitLab Container Registry remain opt-in from the helper script because the live topology requires Vault-backed PAT material (`authref://vault/gitlab#access-token` / `authref://vault/gitlab#registry-basic`) to avoid `401 Unauthorized`. +- Decision: this workstation now has local-only GitLab bootstrap secrets in the dev Vault under `secret/gitlab`, which were generated solely to exercise the GitLab SCM, CI, and registry providers end-to-end during the scratch setup. +- Decision: `src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs` and `MigrationRunner.cs` now detect startup scripts that manage their own `BEGIN`/`COMMIT` lifecycle and execute them outside an outer `NpgsqlTransaction`, which keeps fresh-volume bootstraps deterministic for embedded SQL migrations that use explicit transactions. +- Decision: Findings RLS rollback stays available as `migrations/007_enable_rls_rollback.sql`, but it is excluded from embedded resources so startup only applies forward migrations. The operator-facing rollback path is now documented in `docs/modules/findings-ledger/operations/rls-migration.md` and `docs/contracts/findings-ledger-rls.md`. +- Risk: the machine-local GitLab PAT and Vault secrets are suitable only for this workstation's dev bootstrap path and will need regeneration if GitLab tokens are revoked or rotated. + +## Next Checkpoints +- Re-run `powershell -ExecutionPolicy Bypass -File scripts/register-local-integrations.ps1 -Tenant demo-prod -IncludeGitLab -IncludeGitLabRegistry` after future local compose wipes or secret rotation. +- Re-run `POST http://127.1.0.9/api/v1/advisory-sources/check` when external upstream availability needs to be revalidated. diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs index 41cf6bf09..c570745d8 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs @@ -57,6 +57,11 @@ public static class MigrationCategoryExtensions return MigrationCategory.Seed; } + if (name.Contains("rollback", StringComparison.OrdinalIgnoreCase)) + { + return MigrationCategory.Release; + } + // Try to parse leading digits var numericPrefix = new string(name.TakeWhile(char.IsDigit).ToArray()); if (int.TryParse(numericPrefix, out var prefix)) diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs index 9bdf9f599..39e0a7ef5 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationRunner.cs @@ -278,6 +278,37 @@ public sealed class MigrationRunner : IMigrationRunner _logger.LogInformation("Applying migration {Migration} ({Category}) for {Module}...", migration.Name, migration.Category, ModuleName); var sw = Stopwatch.StartNew(); + var quotedSchema = QuoteIdentifier(SchemaName); + + if (MigrationSqlTransactionClassifier.UsesExplicitTransactionControl(migration.Content)) + { + try + { + await using (var command = new NpgsqlCommand(migration.Content, connection)) + { + command.CommandTimeout = timeoutSeconds; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await using var recordTransaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + await RecordMigrationAsync( + connection, + recordTransaction, + quotedSchema, + migration, + sw.ElapsedMilliseconds, + cancellationToken).ConfigureAwait(false); + await recordTransaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation("Applied migration {Migration} for {Module} in {Duration}ms.", migration.Name, ModuleName, sw.ElapsedMilliseconds); + return sw.ElapsedMilliseconds; + } + catch + { + await ResetTransactionStateAsync(connection, cancellationToken).ConfigureAwait(false); + throw; + } + } await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); @@ -286,9 +317,8 @@ public sealed class MigrationRunner : IMigrationRunner // Bind the search_path to the target module schema for this transaction. // SET LOCAL scopes the change to the current transaction so that unqualified // table names in migration SQL resolve to the module schema, not public. - var quotedSchemaLocal = QuoteIdentifier(SchemaName); await using (var searchPathCommand = new NpgsqlCommand( - $"SET LOCAL search_path TO {quotedSchemaLocal}, public", connection, transaction)) + $"SET LOCAL search_path TO {quotedSchema}, public", connection, transaction)) { await searchPathCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -299,22 +329,13 @@ public sealed class MigrationRunner : IMigrationRunner await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } - await using (var record = new NpgsqlCommand( - $""" - INSERT INTO {SchemaName}.schema_migrations (migration_name, category, checksum, duration_ms, applied_by) - VALUES (@name, @category, @checksum, @duration, @applied_by) - ON CONFLICT (migration_name) DO NOTHING; - """, + await RecordMigrationAsync( connection, - transaction)) - { - record.Parameters.AddWithValue("name", migration.Name); - record.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant()); - record.Parameters.AddWithValue("checksum", migration.Checksum); - record.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds); - record.Parameters.AddWithValue("applied_by", Environment.MachineName); - await record.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } + transaction, + quotedSchema, + migration, + sw.ElapsedMilliseconds, + cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); _logger.LogInformation("Applied migration {Migration} for {Module} in {Duration}ms.", migration.Name, ModuleName, sw.ElapsedMilliseconds); @@ -327,6 +348,45 @@ public sealed class MigrationRunner : IMigrationRunner } } + private static async Task RecordMigrationAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string quotedSchema, + PendingMigration migration, + long durationMs, + CancellationToken cancellationToken) + { + await using var record = new NpgsqlCommand( + $""" + INSERT INTO {quotedSchema}.schema_migrations (migration_name, category, checksum, duration_ms, applied_by) + VALUES (@name, @category, @checksum, @duration, @applied_by) + ON CONFLICT (migration_name) DO NOTHING; + """, + connection, + transaction); + record.Parameters.AddWithValue("name", migration.Name); + record.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant()); + record.Parameters.AddWithValue("checksum", migration.Checksum); + record.Parameters.AddWithValue("duration", (int)durationMs); + record.Parameters.AddWithValue("applied_by", Environment.MachineName); + await record.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ResetTransactionStateAsync( + NpgsqlConnection connection, + CancellationToken cancellationToken) + { + try + { + await using var resetCommand = new NpgsqlCommand("ROLLBACK;", connection); + await resetCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // Best effort only: the original migration exception should remain the failure signal. + } + } + private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken) { var schemaName = QuoteIdentifier(SchemaName); diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationSqlTransactionClassifier.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationSqlTransactionClassifier.cs new file mode 100644 index 000000000..4906a83e2 --- /dev/null +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationSqlTransactionClassifier.cs @@ -0,0 +1,21 @@ +using System.Text.RegularExpressions; + +namespace StellaOps.Infrastructure.Postgres.Migrations; + +internal static partial class MigrationSqlTransactionClassifier +{ + public static bool UsesExplicitTransactionControl(string sql) + { + if (string.IsNullOrWhiteSpace(sql)) + { + return false; + } + + return ExplicitTransactionControlPattern().IsMatch(sql); + } + + [GeneratedRegex( + @"^\s*(BEGIN(?:\s+TRANSACTION)?|START\s+TRANSACTION|COMMIT(?:\s+WORK)?|ROLLBACK(?:\s+WORK)?)\s*;\s*$", + RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.CultureInvariant)] + private static partial Regex ExplicitTransactionControlPattern(); +} diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs index 0a3e4eddb..6c2552d13 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs @@ -365,6 +365,40 @@ public abstract class StartupMigrationHost : IHostedService var sw = Stopwatch.StartNew(); var quotedSchema = QuoteIdentifier(_schemaName); + if (MigrationSqlTransactionClassifier.UsesExplicitTransactionControl(migration.Content)) + { + try + { + await using (var migrationCommand = new NpgsqlCommand(migration.Content, connection)) + { + migrationCommand.CommandTimeout = _options.MigrationTimeoutSeconds; + await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await using var recordTransaction = await connection.BeginTransactionAsync(cancellationToken) + .ConfigureAwait(false); + await RecordMigrationAsync( + connection, + recordTransaction, + quotedSchema, + migration, + sw.ElapsedMilliseconds, + cancellationToken).ConfigureAwait(false); + await recordTransaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Migration: {Migration} completed in {Duration}ms.", + migration.Name, sw.ElapsedMilliseconds); + + return; + } + catch + { + await ResetTransactionStateAsync(connection, cancellationToken).ConfigureAwait(false); + throw; + } + } + await using var transaction = await connection.BeginTransactionAsync(cancellationToken) .ConfigureAwait(false); @@ -387,23 +421,13 @@ public abstract class StartupMigrationHost : IHostedService } // Record migration - await using (var recordCommand = new NpgsqlCommand( - $""" - INSERT INTO {quotedSchema}.schema_migrations - (migration_name, category, checksum, duration_ms, applied_by) - VALUES (@name, @category, @checksum, @duration, @applied_by) - ON CONFLICT (migration_name) DO NOTHING - """, + await RecordMigrationAsync( connection, - transaction)) - { - recordCommand.Parameters.AddWithValue("name", migration.Name); - recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant()); - recordCommand.Parameters.AddWithValue("checksum", migration.Checksum); - recordCommand.Parameters.AddWithValue("duration", (int)sw.ElapsedMilliseconds); - recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName); - await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - } + transaction, + quotedSchema, + migration, + sw.ElapsedMilliseconds, + cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); @@ -418,6 +442,47 @@ public abstract class StartupMigrationHost : IHostedService } } + private static async Task RecordMigrationAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string quotedSchema, + PendingMigration migration, + long durationMs, + CancellationToken cancellationToken) + { + await using var recordCommand = new NpgsqlCommand( + $""" + INSERT INTO {quotedSchema}.schema_migrations + (migration_name, category, checksum, duration_ms, applied_by) + VALUES (@name, @category, @checksum, @duration, @applied_by) + ON CONFLICT (migration_name) DO NOTHING + """, + connection, + transaction); + recordCommand.Parameters.AddWithValue("name", migration.Name); + recordCommand.Parameters.AddWithValue("category", migration.Category.ToString().ToLowerInvariant()); + recordCommand.Parameters.AddWithValue("checksum", migration.Checksum); + recordCommand.Parameters.AddWithValue("duration", (int)durationMs); + recordCommand.Parameters.AddWithValue("applied_by", Environment.MachineName); + await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static async Task ResetTransactionStateAsync( + NpgsqlConnection connection, + CancellationToken cancellationToken) + { + try + { + await using var resetCommand = new NpgsqlCommand("ROLLBACK;", connection); + await resetCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + // Best effort: if the connection is not currently inside a transaction, + // there is nothing to reset before the original migration exception surfaces. + } + } + private static long ComputeLockKey(string schemaName) { // Use a deterministic hash of the schema name as the lock key diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.RealWorld.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.RealWorld.cs index 0c9b8cbc8..67081a8f7 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.RealWorld.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.RealWorld.cs @@ -13,6 +13,7 @@ public partial class MigrationCategoryTests [InlineData("004_add_audit_columns.sql", MigrationCategory.Startup)] [InlineData("100_drop_legacy_auth_columns.sql", MigrationCategory.Release)] [InlineData("101_migrate_user_roles.sql", MigrationCategory.Release)] + [InlineData("007_enable_rls_rollback.sql", MigrationCategory.Release)] [InlineData("S001_default_admin_role.sql", MigrationCategory.Seed)] [InlineData("S002_system_permissions.sql", MigrationCategory.Seed)] [InlineData("DM001_BackfillTenantIds.sql", MigrationCategory.Data)] diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Helpers.Schema.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Helpers.Schema.cs index 314bc74b2..bac06b5e0 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Helpers.Schema.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Helpers.Schema.cs @@ -56,6 +56,19 @@ public sealed partial class StartupMigrationHostTests return result is true; } + private async Task GetRowCountAsync(string schemaName, string tableName) + { + await using var conn = new NpgsqlConnection(ConnectionString); + await conn.OpenAsync(); + + await using var cmd = new NpgsqlCommand( + $"SELECT COUNT(*) FROM \"{schemaName}\".\"{tableName}\"", + conn); + + var result = await cmd.ExecuteScalarAsync(); + return Convert.ToInt32(result); + } + private async Task CorruptChecksumAsync(string schemaName, string migrationName) { await using var conn = new NpgsqlConnection(ConnectionString); diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs index ae56e1f64..6d8f52e85 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.MigrationExecution.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; using StellaOps.Infrastructure.Postgres.Migrations; using Xunit; @@ -52,6 +53,53 @@ public sealed partial class StartupMigrationHostTests finalCount.Should().Be(initialCount); } + [Fact] + public async Task StartAsync_WithExplicitTransactionMigration_AppliesAndRecordsItAsync() + { + // Arrange + var schemaName = $"test_{Guid.NewGuid():N}"[..20]; + var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false }; + var host = CreateTestHost(schemaName, options: options); + + // Act + await host.StartAsync(CancellationToken.None); + + // Assert + var migrations = await GetAppliedMigrationNamesAsync(schemaName); + migrations.Should().Contain("003_explicit_transaction.sql"); + var markerTableExists = await TableExistsAsync(schemaName, "explicit_transaction_markers"); + markerTableExists.Should().BeTrue(); + + var markerCount = await GetRowCountAsync(schemaName, "explicit_transaction_markers"); + markerCount.Should().Be(1); + } + + [Fact] + public async Task MigrationRunner_RunFromAssemblyAsync_WithExplicitTransactionMigration_AppliesAndRecordsItAsync() + { + // Arrange + var schemaName = $"test_{Guid.NewGuid():N}"[..20]; + var runner = new MigrationRunner( + ConnectionString, + schemaName, + "TestRunner", + NullLogger.Instance); + + // Act + var result = await runner.RunFromAssemblyAsync( + typeof(StartupMigrationHostTests).Assembly, + resourcePrefix: "TestMigrations", + options: new MigrationRunOptions()); + + // Assert + result.Success.Should().BeTrue(); + var migrations = await GetAppliedMigrationNamesAsync(schemaName); + migrations.Should().Contain("003_explicit_transaction.sql"); + + var markerCount = await GetRowCountAsync(schemaName, "explicit_transaction_markers"); + markerCount.Should().Be(1); + } + [Fact] public async Task StartAsync_CreatesSchemaAndMigrationsTableAsync() { diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/TestMigrations/003_explicit_transaction.sql b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/TestMigrations/003_explicit_transaction.sql new file mode 100644 index 000000000..ba65adcd0 --- /dev/null +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/TestMigrations/003_explicit_transaction.sql @@ -0,0 +1,12 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS explicit_transaction_markers ( + id INT PRIMARY KEY, + note TEXT NOT NULL +); + +INSERT INTO explicit_transaction_markers (id, note) +VALUES (1, 'applied') +ON CONFLICT (id) DO NOTHING; + +COMMIT;