diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs index e231fd3f0..be6b90aaa 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Create.cs @@ -7,7 +7,7 @@ namespace StellaOps.AdvisoryAI.Attestation; public sealed partial class AiAttestationService { /// - public Task CreateRunAttestationAsync( + public async Task CreateRunAttestationAsync( AiRunAttestation attestation, bool sign = true, CancellationToken ct = default) @@ -24,15 +24,11 @@ public sealed partial class AiAttestationService dsseEnvelope = CreateMockDsseEnvelope(AiRunAttestation.PredicateType, json); } - var stored = new StoredAttestation( - attestation.RunId, - AiRunAttestation.PredicateType, - json, - digest, - dsseEnvelope, - now); - - _runAttestations[attestation.RunId] = stored; + await _store.StoreRunAttestationAsync(attestation, ct).ConfigureAwait(false); + if (dsseEnvelope is not null) + { + await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false); + } _logger.LogInformation( "Created run attestation {RunId} with digest {Digest}, signed={Signed}", @@ -40,7 +36,7 @@ public sealed partial class AiAttestationService digest, sign); - return Task.FromResult(new AiAttestationResult + return new AiAttestationResult { AttestationId = attestation.RunId, Digest = digest, @@ -48,11 +44,11 @@ public sealed partial class AiAttestationService DsseEnvelope = dsseEnvelope, StorageUri = $"stella://ai-attestation/run/{attestation.RunId}", CreatedAt = now - }); + }; } /// - public Task CreateClaimAttestationAsync( + public async Task CreateClaimAttestationAsync( AiClaimAttestation attestation, bool sign = true, CancellationToken ct = default) @@ -67,22 +63,18 @@ public sealed partial class AiAttestationService dsseEnvelope = CreateMockDsseEnvelope(AiClaimAttestation.PredicateType, json); } - var stored = new StoredAttestation( - attestation.ClaimId, - AiClaimAttestation.PredicateType, - json, - digest, - dsseEnvelope, - now); - - _claimAttestations[attestation.ClaimId] = stored; + await _store.StoreClaimAttestationAsync(attestation, ct).ConfigureAwait(false); + if (dsseEnvelope is not null) + { + await _store.StoreSignedEnvelopeAsync(attestation.RunId, dsseEnvelope, ct).ConfigureAwait(false); + } _logger.LogDebug( "Created claim attestation {ClaimId} for run {RunId}", attestation.ClaimId, attestation.RunId); - return Task.FromResult(new AiAttestationResult + return new AiAttestationResult { AttestationId = attestation.ClaimId, Digest = digest, @@ -90,6 +82,6 @@ public sealed partial class AiAttestationService DsseEnvelope = dsseEnvelope, StorageUri = $"stella://ai-attestation/claim/{attestation.ClaimId}", CreatedAt = now - }); + }; } } diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs index b67b1c6f7..007d9f498 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Read.cs @@ -1,5 +1,4 @@ using StellaOps.AdvisoryAI.Attestation.Models; -using System.Text.Json; namespace StellaOps.AdvisoryAI.Attestation; @@ -9,47 +8,29 @@ public sealed partial class AiAttestationService public Task GetRunAttestationAsync( string runId, CancellationToken ct = default) - { - if (!_runAttestations.TryGetValue(runId, out var stored)) - { - return Task.FromResult(null); - } - - var attestation = JsonSerializer.Deserialize( - stored.Json, - AiAttestationJsonContext.Default.AiRunAttestation); - - return Task.FromResult(attestation); - } + => _store.GetRunAttestationAsync(runId, ct); /// - public Task> GetClaimAttestationsAsync( + public async Task> GetClaimAttestationsAsync( string runId, CancellationToken ct = default) { - var claims = _claimAttestations.Values - .Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiClaimAttestation)) - .Where(c => c != null && c.RunId == runId) - .Cast() - .ToList(); - - return Task.FromResult>(claims); + var claims = await _store.GetClaimAttestationsAsync(runId, ct).ConfigureAwait(false); + return claims; } /// - public Task> ListRecentAttestationsAsync( + public async Task> ListRecentAttestationsAsync( string tenantId, int limit = 100, CancellationToken ct = default) { - var attestations = _runAttestations.Values - .OrderByDescending(s => s.CreatedAt) - .Select(s => JsonSerializer.Deserialize(s.Json, AiAttestationJsonContext.Default.AiRunAttestation)) - .Where(a => a != null && a.TenantId == tenantId) - .Cast() + var windowStart = DateTimeOffset.UnixEpoch; + var windowEnd = _timeProvider.GetUtcNow().AddYears(1); + var attestations = await _store.GetByTenantAsync(tenantId, windowStart, windowEnd, ct).ConfigureAwait(false); + return attestations + .OrderByDescending(attestation => attestation.CompletedAt) .Take(limit) - .ToList(); - - return Task.FromResult>(attestations); + .ToArray(); } } diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs index 657ba2455..759c39c9e 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.Verify.cs @@ -1,92 +1,65 @@ using Microsoft.Extensions.Logging; -using StellaOps.AdvisoryAI.Attestation.Models; -using System.Text.Json; namespace StellaOps.AdvisoryAI.Attestation; public sealed partial class AiAttestationService { /// - public Task VerifyRunAttestationAsync( + public async Task VerifyRunAttestationAsync( string runId, CancellationToken ct = default) { var now = _timeProvider.GetUtcNow(); - - if (!_runAttestations.TryGetValue(runId, out var stored)) + var attestation = await _store.GetRunAttestationAsync(runId, ct).ConfigureAwait(false); + if (attestation is null) { - return Task.FromResult(AiAttestationVerificationResult.Failure( + return AiAttestationVerificationResult.Failure( now, - $"Run attestation {runId} not found")); - } - - // Verify digest - var attestation = JsonSerializer.Deserialize( - stored.Json, - AiAttestationJsonContext.Default.AiRunAttestation); - - if (attestation == null) - { - return Task.FromResult(AiAttestationVerificationResult.Failure( - now, - "Failed to deserialize attestation")); + $"Run attestation {runId} not found"); } var computedDigest = attestation.ComputeDigest(); - if (computedDigest != stored.Digest) + var storedEnvelope = await _store.GetSignedEnvelopeAsync(runId, ct).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(computedDigest)) { - return Task.FromResult(AiAttestationVerificationResult.Failure( + return AiAttestationVerificationResult.Failure( now, "Digest mismatch", - digestValid: false)); + digestValid: false); } - // In production, verify signature via signer service - bool? signatureValid = stored.DsseEnvelope != null ? true : null; - _logger.LogDebug("Verified run attestation {RunId}", runId); - return Task.FromResult(AiAttestationVerificationResult.Success( + return AiAttestationVerificationResult.Success( now, - stored.DsseEnvelope != null ? "ai-attestation-key" : null)); + storedEnvelope is not null ? "ai-attestation-key" : null); } /// - public Task VerifyClaimAttestationAsync( + public async Task VerifyClaimAttestationAsync( string claimId, CancellationToken ct = default) { var now = _timeProvider.GetUtcNow(); - - if (!_claimAttestations.TryGetValue(claimId, out var stored)) + var attestation = await _store.GetClaimAttestationAsync(claimId, ct).ConfigureAwait(false); + if (attestation is null) { - return Task.FromResult(AiAttestationVerificationResult.Failure( + return AiAttestationVerificationResult.Failure( now, - $"Claim attestation {claimId} not found")); - } - - var attestation = JsonSerializer.Deserialize( - stored.Json, - AiAttestationJsonContext.Default.AiClaimAttestation); - - if (attestation == null) - { - return Task.FromResult(AiAttestationVerificationResult.Failure( - now, - "Failed to deserialize attestation")); + $"Claim attestation {claimId} not found"); } var computedDigest = attestation.ComputeDigest(); - if (computedDigest != stored.Digest) + if (string.IsNullOrWhiteSpace(computedDigest)) { - return Task.FromResult(AiAttestationVerificationResult.Failure( + return AiAttestationVerificationResult.Failure( now, "Digest mismatch", - digestValid: false)); + digestValid: false); } - return Task.FromResult(AiAttestationVerificationResult.Success( + return AiAttestationVerificationResult.Success( now, - stored.DsseEnvelope != null ? "ai-attestation-key" : null)); + null); } } diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs index 0523b6643..ec00d8ce0 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/AiAttestationService.cs @@ -3,30 +3,32 @@ // using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; +using StellaOps.AdvisoryAI.Attestation.Storage; namespace StellaOps.AdvisoryAI.Attestation; /// -/// In-memory implementation of AI attestation service. +/// Store-backed implementation of AI attestation service. /// Sprint: SPRINT_20260109_011_001 Task: AIAT-003 /// /// -/// This implementation stores attestations in memory. For production, -/// use a database-backed implementation with signing integration. +/// Attestation records are persisted through . +/// The default runtime wiring decides whether that store is durable or +/// in-memory for development/testing. /// public sealed partial class AiAttestationService : IAiAttestationService { private readonly TimeProvider _timeProvider; + private readonly IAiAttestationStore _store; private readonly ILogger _logger; - private readonly ConcurrentDictionary _runAttestations = new(); - private readonly ConcurrentDictionary _claimAttestations = new(); public AiAttestationService( TimeProvider timeProvider, + IAiAttestationStore store, ILogger logger) { _timeProvider = timeProvider; + _store = store; _logger = logger; } } diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs index 8a9157c22..3f8f198d6 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/IAiAttestationStore.Query.cs @@ -13,6 +13,14 @@ public partial interface IAiAttestationStore /// The attestation or null if not found. Task GetRunAttestationAsync(string runId, CancellationToken ct); + /// + /// Gets a claim attestation by claim ID. + /// + /// The claim ID. + /// Cancellation token. + /// The claim attestation or null if not found. + Task GetClaimAttestationAsync(string claimId, CancellationToken ct); + /// /// Get all claim attestations for a run. /// diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs index ef3324c16..7c584dbfd 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Query.cs @@ -5,6 +5,13 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage; public sealed partial class InMemoryAiAttestationStore { + /// + public Task GetClaimAttestationAsync(string claimId, CancellationToken ct) + { + _claimAttestationsById.TryGetValue(claimId, out var attestation); + return Task.FromResult(attestation); + } + /// public Task GetRunAttestationAsync(string runId, CancellationToken ct) { diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs index 7f3ed672b..912f3cf2b 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.Store.cs @@ -30,6 +30,7 @@ public sealed partial class InMemoryAiAttestationStore claims.Add(attestation); } + _claimAttestationsById[attestation.ClaimId] = attestation; _digestIndex[attestation.ContentDigest] = attestation; _logger.LogDebug( diff --git a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs index bb6949bde..e335de2b6 100644 --- a/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs +++ b/src/__Libraries/StellaOps.AdvisoryAI.Attestation/Storage/InMemoryAiAttestationStore.cs @@ -18,6 +18,7 @@ public sealed partial class InMemoryAiAttestationStore : IAiAttestationStore private readonly ConcurrentDictionary _runAttestations = new(); private readonly ConcurrentDictionary _signedEnvelopes = new(); private readonly ConcurrentDictionary> _claimAttestations = new(); + private readonly ConcurrentDictionary _claimAttestationsById = new(); private readonly ConcurrentDictionary _digestIndex = new(); private readonly ILogger _logger; diff --git a/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs b/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs index 860ad5889..9054aa5da 100644 --- a/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs +++ b/src/__Libraries/StellaOps.Doctor/AdvisoryAI/IEvidenceSchemaRegistry.cs @@ -30,9 +30,36 @@ public sealed record EvidenceFieldSchema } /// -/// In-memory deterministic registry implementation. +/// Built-in deterministic registry implementation used by live runtime wiring. +/// +public sealed class BuiltInEvidenceSchemaRegistry : IEvidenceSchemaRegistry +{ + private readonly EvidenceSchemaRegistryState _state = EvidenceSchemaRegistryDefaults.Create(); + + public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName); + + public IReadOnlyDictionary GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId); + + public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema); +} + +/// +/// Mutable in-memory deterministic registry kept for tests and local composition. /// public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry +{ + private readonly EvidenceSchemaRegistryState _state = new(); + + public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName); + + public IReadOnlyDictionary GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId); + + public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema); + + public void RegisterCommonSchemas() => EvidenceSchemaRegistryDefaults.RegisterCommonSchemas(_state); +} + +internal sealed class EvidenceSchemaRegistryState { private readonly object _sync = new(); private readonly Dictionary> _schemas = @@ -88,10 +115,22 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry checkSchemas[fieldName] = schema; } } +} - public void RegisterCommonSchemas() +internal static class EvidenceSchemaRegistryDefaults +{ + public static EvidenceSchemaRegistryState Create() { - RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema + var state = new EvidenceSchemaRegistryState(); + RegisterCommonSchemas(state); + return state; + } + + public static void RegisterCommonSchemas(EvidenceSchemaRegistryState state) + { + ArgumentNullException.ThrowIfNull(state); + + state.RegisterSchema("*", "connection_latency_ms", new EvidenceFieldSchema { Description = "Time to establish connection.", ExpectedRange = "< 1000", @@ -100,7 +139,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry DiscriminatesFor = ["network_issue", "service_overloaded"] }); - RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema + state.RegisterSchema("*", "disk_usage_percent", new EvidenceFieldSchema { Description = "Disk space utilization percentage.", ExpectedRange = "< 80", @@ -109,7 +148,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry DiscriminatesFor = ["disk_full", "log_growth"] }); - RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema + state.RegisterSchema("*", "memory_usage_percent", new EvidenceFieldSchema { Description = "Memory utilization percentage.", ExpectedRange = "< 85", @@ -118,7 +157,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry DiscriminatesFor = ["memory_pressure", "load_spike"] }); - RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema + state.RegisterSchema("*", "cpu_usage_percent", new EvidenceFieldSchema { Description = "CPU utilization percentage.", ExpectedRange = "< 80", @@ -127,7 +166,7 @@ public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry DiscriminatesFor = ["cpu_saturation", "runaway_process"] }); - RegisterSchema("*", "queue_depth", new EvidenceFieldSchema + state.RegisterSchema("*", "queue_depth", new EvidenceFieldSchema { Description = "Pending items waiting in queue.", ExpectedRange = "< 100", diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs index c570745d8..388bd83ea 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/MigrationCategory.cs @@ -20,7 +20,7 @@ public enum MigrationCategory Release, /// - /// Seed data that is inserted once. + /// Optional seed/demo data that is inserted only when an operator runs it explicitly. /// Prefix: S001-S999 /// Seed, @@ -83,10 +83,11 @@ public static class MigrationCategoryExtensions /// Returns true if this migration should run automatically at startup. /// public static bool IsAutomatic(this MigrationCategory category) => - category is MigrationCategory.Startup or MigrationCategory.Seed; + category is MigrationCategory.Startup; /// - /// Returns true if this migration requires manual CLI execution. + /// Returns true if this migration requires manual CLI execution before startup may proceed. + /// Seed migrations are intentionally excluded because they are opt-in/manual only and must not block runtime. /// public static bool RequiresManualExecution(this MigrationCategory category) => category is MigrationCategory.Release or MigrationCategory.Data; diff --git a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs index 6c2552d13..394feddbc 100644 --- a/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs +++ b/src/__Libraries/StellaOps.Infrastructure.Postgres/Migrations/StartupMigrationHost.cs @@ -15,8 +15,8 @@ namespace StellaOps.Infrastructure.Postgres.Migrations; /// This service: /// - Acquires an advisory lock to prevent concurrent migrations /// - Validates checksums of already-applied migrations -/// - Blocks startup if pending release migrations exist -/// - Runs only Category A (startup) and seed migrations automatically +/// - Blocks startup if pending release/data migrations exist +/// - Runs only startup migrations automatically; seed migrations remain operator-invoked /// public abstract class StartupMigrationHost : IHostedService { @@ -116,37 +116,64 @@ public abstract class StartupMigrationHost : IHostedService } } - // Step 5: Check for pending release migrations - var pendingRelease = allMigrations + // Step 5: Check for pending release/data migrations + var pendingManual = allMigrations .Where(m => !appliedMigrations.ContainsKey(m.Name)) .Where(m => m.Category.RequiresManualExecution()) .ToList(); - if (pendingRelease.Count > 0) + if (pendingManual.Count > 0) { _logger.LogError( - "Migration: {Count} pending release migration(s) require manual execution for {Module}:", - pendingRelease.Count, _moduleName); + "Migration: {Count} pending manual migration(s) require explicit execution before {Module} startup can converge:", + pendingManual.Count, + _moduleName); - foreach (var migration in pendingRelease) + foreach (var migration in pendingManual) { _logger.LogError(" - {Migration} (Category: {Category})", migration.Name, migration.Category); } - _logger.LogError("Run: stellaops db migrate --module {Module} --category release", _moduleName); + foreach (var category in pendingManual + .Select(static migration => migration.Category) + .Distinct() + .OrderBy(static category => category.ToString(), StringComparer.Ordinal)) + { + _logger.LogError( + "Run: stella system migrations-run --module {Module} --category {Category}", + _moduleName, + category.ToString().ToLowerInvariant()); + } if (_options.FailOnPendingReleaseMigrations) { _lifetime.StopApplication(); throw new InvalidOperationException( - $"Pending release migrations block startup for {_moduleName}. Run CLI migration first."); + $"Pending manual migrations block startup for {_moduleName}. Run CLI migration first."); } } + var pendingSeed = allMigrations + .Where(m => !appliedMigrations.ContainsKey(m.Name)) + .Where(static m => m.Category == MigrationCategory.Seed) + .OrderBy(m => m.Name) + .ToList(); + + if (pendingSeed.Count > 0) + { + _logger.LogInformation( + "Migration: {Count} optional seed migration(s) are pending for {Module}. They remain manual-only and will not run at startup.", + pendingSeed.Count, + _moduleName); + _logger.LogInformation( + "Run manually when needed: stella system migrations-run --module {Module} --category seed", + _moduleName); + } + // Step 6: Execute pending startup migrations var pendingStartup = allMigrations .Where(m => !appliedMigrations.ContainsKey(m.Name)) - .Where(m => m.Category.IsAutomatic()) + .Where(static m => m.Category == MigrationCategory.Startup) .OrderBy(m => m.Name) .ToList(); diff --git a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs index 54c4fb48e..5f075ec67 100644 --- a/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs +++ b/src/__Libraries/StellaOps.TestKit/Fixtures/ValkeyFixture.cs @@ -94,7 +94,10 @@ public sealed class ValkeyFixture : IAsyncLifetime Port = _container.GetMappedPublicPort(6379); ConnectionString = $"{Host}:{Port}"; - _connection = await ConnectionMultiplexer.ConnectAsync(ConnectionString); + var redisOptions = ConfigurationOptions.Parse(ConnectionString); + redisOptions.AllowAdmin = true; + + _connection = await ConnectionMultiplexer.ConnectAsync(redisOptions); } /// @@ -250,4 +253,3 @@ public sealed class ValkeyTestSession : IAsyncDisposable } } - diff --git a/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs b/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs index b6c841ef2..5f25fbcf1 100644 --- a/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs +++ b/src/__Libraries/__Tests/StellaOps.AdvisoryAI.Attestation.Tests/AiAttestationServiceTests.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; using StellaOps.AdvisoryAI.Attestation; using StellaOps.AdvisoryAI.Attestation.Models; +using StellaOps.AdvisoryAI.Attestation.Storage; using Xunit; namespace StellaOps.AdvisoryAI.Attestation.Tests; @@ -24,7 +25,10 @@ public sealed partial class AiAttestationServiceTests public AiAttestationServiceTests() { _timeProvider.SetUtcNow(FixedUtcNow); - _service = new AiAttestationService(_timeProvider, NullLogger.Instance); + _service = new AiAttestationService( + _timeProvider, + new InMemoryAiAttestationStore(NullLogger.Instance), + NullLogger.Instance); } private static AiRunAttestation CreateSampleRunAttestation() diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs index 48e3567ba..c7c2ae64e 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/MigrationCategoryTests.Flags.cs @@ -8,7 +8,7 @@ public partial class MigrationCategoryTests { [Theory] [InlineData(MigrationCategory.Startup, true)] - [InlineData(MigrationCategory.Seed, true)] + [InlineData(MigrationCategory.Seed, false)] [InlineData(MigrationCategory.Release, false)] [InlineData(MigrationCategory.Data, false)] public void IsAutomatic_ReturnsExpectedValue(MigrationCategory category, bool expected) @@ -31,15 +31,20 @@ public partial class MigrationCategoryTests [Theory] [InlineData(MigrationCategory.Startup)] [InlineData(MigrationCategory.Release)] - [InlineData(MigrationCategory.Seed)] [InlineData(MigrationCategory.Data)] - public void IsAutomatic_And_RequiresManualExecution_AreMutuallyExclusive(MigrationCategory category) + public void BlockingCategories_AreEitherAutomaticOrManual(MigrationCategory category) { var isAutomatic = category.IsAutomatic(); var requiresManual = category.RequiresManualExecution(); - // They should be opposite of each other (isAutomatic ^ requiresManual).Should().BeTrue( - $"Category {category} should be either automatic OR manual, not both or neither"); + $"Category {category} should be either automatic OR rollout-blocking manual, not both or neither"); + } + + [Fact] + public void Seed_IsManualOptInWithoutBlockingStartup() + { + MigrationCategory.Seed.IsAutomatic().Should().BeFalse(); + MigrationCategory.Seed.RequiresManualExecution().Should().BeFalse(); } } 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 6d8f52e85..b51ac408c 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 @@ -23,11 +23,11 @@ public sealed partial class StartupMigrationHostTests var appliedCount = await GetAppliedMigrationCountAsync(schemaName); appliedCount.Should().BeGreaterThan(0); - // Verify specific startup/seed migrations were applied (not release) + // Verify only startup migrations were auto-applied (not seed or release) var migrations = await GetAppliedMigrationNamesAsync(schemaName); migrations.Should().Contain("001_create_test_table.sql"); migrations.Should().Contain("002_add_column.sql"); - migrations.Should().Contain("S001_seed_data.sql"); + migrations.Should().NotContain("S001_seed_data.sql"); migrations.Should().NotContain("100_release_migration.sql"); // Release not auto-applied } @@ -100,6 +100,38 @@ public sealed partial class StartupMigrationHostTests markerCount.Should().Be(1); } + [Fact] + public async Task MigrationRunner_RunFromAssemblyAsync_WithSeedCategory_AppliesSeedOnlyWhenRequestedAsync() + { + // Arrange + var schemaName = $"test_{Guid.NewGuid():N}"[..20]; + var options = new StartupMigrationOptions { FailOnPendingReleaseMigrations = false }; + var host = CreateTestHost(schemaName, options: options); + await host.StartAsync(CancellationToken.None); + + var runner = new MigrationRunner( + ConnectionString, + schemaName, + "TestRunner", + NullLogger.Instance); + + // Act + var result = await runner.RunFromAssemblyAsync( + typeof(StartupMigrationHostTests).Assembly, + resourcePrefix: "TestMigrations", + options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed }); + + // Assert + result.Success.Should().BeTrue(); + result.AppliedCount.Should().Be(1); + + var migrations = await GetAppliedMigrationNamesAsync(schemaName); + migrations.Should().Contain("S001_seed_data.sql"); + + var seededRowCount = await GetRowCountAsync(schemaName, "test_table"); + seededRowCount.Should().Be(1); + } + [Fact] public async Task StartAsync_CreatesSchemaAndMigrationsTableAsync() { diff --git a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs index c367b4d9b..8675801db 100644 --- a/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs +++ b/src/__Libraries/__Tests/StellaOps.Infrastructure.Postgres.Tests/Migrations/StartupMigrationHostTests.Recording.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using StellaOps.Infrastructure.Postgres.Migrations; using Xunit; @@ -36,17 +37,29 @@ public sealed partial class StartupMigrationHostTests } [Fact] - public async Task StartAsync_SeedMigrations_RecordedAsSeedCategoryAsync() + public async Task MigrationRunner_RunFromAssemblyAsync_SeedMigrations_RecordSeedCategoryAsync() { // 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); + var runner = new MigrationRunner( + ConnectionString, + schemaName, + "TestRunner", + NullLogger.Instance); + + // Act + var result = await runner.RunFromAssemblyAsync( + typeof(StartupMigrationHostTests).Assembly, + resourcePrefix: "TestMigrations", + options: new MigrationRunOptions { CategoryFilter = MigrationCategory.Seed }); + // Assert + result.Success.Should().BeTrue(); + await using var conn = new NpgsqlConnection(ConnectionString); await conn.OpenAsync();