chore(libs): infrastructure postgres host + attestation slicing + testkit
Shared infrastructure supporting the truthful runtime persistence cutover sprints — no dedicated sprint owner, these libs are consumed by multiple services. - Infrastructure.Postgres: MigrationCategory + StartupMigrationHost + tests (MigrationExecution, Recording, Flags). - AdvisoryAI.Attestation: slice AiAttestationService into partial files (Create/Read/Verify), align IAiAttestationStore + InMemory store, service tests. - TestKit: ValkeyFixture for tests that need a shared valkey instance. - Doctor/AdvisoryAI/IEvidenceSchemaRegistry: shared interface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ namespace StellaOps.AdvisoryAI.Attestation;
|
||||
public sealed partial class AiAttestationService
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<AiAttestationResult> CreateRunAttestationAsync(
|
||||
public async Task<AiAttestationResult> 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
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AiAttestationResult> CreateClaimAttestationAsync(
|
||||
public async Task<AiAttestationResult> 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
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AiRunAttestation?> GetRunAttestationAsync(
|
||||
string runId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!_runAttestations.TryGetValue(runId, out var stored))
|
||||
{
|
||||
return Task.FromResult<AiRunAttestation?>(null);
|
||||
}
|
||||
|
||||
var attestation = JsonSerializer.Deserialize(
|
||||
stored.Json,
|
||||
AiAttestationJsonContext.Default.AiRunAttestation);
|
||||
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
=> _store.GetRunAttestationAsync(runId, ct);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<AiClaimAttestation>> GetClaimAttestationsAsync(
|
||||
public async Task<IReadOnlyList<AiClaimAttestation>> 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<AiClaimAttestation>()
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AiClaimAttestation>>(claims);
|
||||
var claims = await _store.GetClaimAttestationsAsync(runId, ct).ConfigureAwait(false);
|
||||
return claims;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<AiRunAttestation>> ListRecentAttestationsAsync(
|
||||
public async Task<IReadOnlyList<AiRunAttestation>> 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<AiRunAttestation>()
|
||||
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<IReadOnlyList<AiRunAttestation>>(attestations);
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<AiAttestationVerificationResult> VerifyRunAttestationAsync(
|
||||
public async Task<AiAttestationVerificationResult> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AiAttestationVerificationResult> VerifyClaimAttestationAsync(
|
||||
public async Task<AiAttestationVerificationResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,32 @@
|
||||
// </copyright>
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
|
||||
namespace StellaOps.AdvisoryAI.Attestation;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of AI attestation service.
|
||||
/// Store-backed implementation of AI attestation service.
|
||||
/// Sprint: SPRINT_20260109_011_001 Task: AIAT-003
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This implementation stores attestations in memory. For production,
|
||||
/// use a database-backed implementation with signing integration.
|
||||
/// Attestation records are persisted through <see cref="IAiAttestationStore"/>.
|
||||
/// The default runtime wiring decides whether that store is durable or
|
||||
/// in-memory for development/testing.
|
||||
/// </remarks>
|
||||
public sealed partial class AiAttestationService : IAiAttestationService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IAiAttestationStore _store;
|
||||
private readonly ILogger<AiAttestationService> _logger;
|
||||
private readonly ConcurrentDictionary<string, StoredAttestation> _runAttestations = new();
|
||||
private readonly ConcurrentDictionary<string, StoredAttestation> _claimAttestations = new();
|
||||
|
||||
public AiAttestationService(
|
||||
TimeProvider timeProvider,
|
||||
IAiAttestationStore store,
|
||||
ILogger<AiAttestationService> logger)
|
||||
{
|
||||
_timeProvider = timeProvider;
|
||||
_store = store;
|
||||
_logger = logger;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ public partial interface IAiAttestationStore
|
||||
/// <returns>The attestation or null if not found.</returns>
|
||||
Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a claim attestation by claim ID.
|
||||
/// </summary>
|
||||
/// <param name="claimId">The claim ID.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The claim attestation or null if not found.</returns>
|
||||
Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Get all claim attestations for a run.
|
||||
/// </summary>
|
||||
|
||||
@@ -5,6 +5,13 @@ namespace StellaOps.AdvisoryAI.Attestation.Storage;
|
||||
|
||||
public sealed partial class InMemoryAiAttestationStore
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public Task<AiClaimAttestation?> GetClaimAttestationAsync(string claimId, CancellationToken ct)
|
||||
{
|
||||
_claimAttestationsById.TryGetValue(claimId, out var attestation);
|
||||
return Task.FromResult(attestation);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<AiRunAttestation?> GetRunAttestationAsync(string runId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed partial class InMemoryAiAttestationStore
|
||||
claims.Add(attestation);
|
||||
}
|
||||
|
||||
_claimAttestationsById[attestation.ClaimId] = attestation;
|
||||
_digestIndex[attestation.ContentDigest] = attestation;
|
||||
|
||||
_logger.LogDebug(
|
||||
|
||||
@@ -18,6 +18,7 @@ public sealed partial class InMemoryAiAttestationStore : IAiAttestationStore
|
||||
private readonly ConcurrentDictionary<string, AiRunAttestation> _runAttestations = new();
|
||||
private readonly ConcurrentDictionary<string, object> _signedEnvelopes = new();
|
||||
private readonly ConcurrentDictionary<string, List<AiClaimAttestation>> _claimAttestations = new();
|
||||
private readonly ConcurrentDictionary<string, AiClaimAttestation> _claimAttestationsById = new();
|
||||
private readonly ConcurrentDictionary<string, AiClaimAttestation> _digestIndex = new();
|
||||
private readonly ILogger<InMemoryAiAttestationStore> _logger;
|
||||
|
||||
|
||||
@@ -30,9 +30,36 @@ public sealed record EvidenceFieldSchema
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory deterministic registry implementation.
|
||||
/// Built-in deterministic registry implementation used by live runtime wiring.
|
||||
/// </summary>
|
||||
public sealed class BuiltInEvidenceSchemaRegistry : IEvidenceSchemaRegistry
|
||||
{
|
||||
private readonly EvidenceSchemaRegistryState _state = EvidenceSchemaRegistryDefaults.Create();
|
||||
|
||||
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
|
||||
|
||||
public IReadOnlyDictionary<string, EvidenceFieldSchema> GetCheckSchemas(string checkId) => _state.GetCheckSchemas(checkId);
|
||||
|
||||
public void RegisterSchema(string checkId, string fieldName, EvidenceFieldSchema schema) => _state.RegisterSchema(checkId, fieldName, schema);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable in-memory deterministic registry kept for tests and local composition.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEvidenceSchemaRegistry : IEvidenceSchemaRegistry
|
||||
{
|
||||
private readonly EvidenceSchemaRegistryState _state = new();
|
||||
|
||||
public EvidenceFieldSchema? GetFieldSchema(string checkId, string fieldName) => _state.GetFieldSchema(checkId, fieldName);
|
||||
|
||||
public IReadOnlyDictionary<string, EvidenceFieldSchema> 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<string, Dictionary<string, EvidenceFieldSchema>> _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",
|
||||
|
||||
@@ -20,7 +20,7 @@ public enum MigrationCategory
|
||||
Release,
|
||||
|
||||
/// <summary>
|
||||
/// Seed data that is inserted once.
|
||||
/// Optional seed/demo data that is inserted only when an operator runs it explicitly.
|
||||
/// Prefix: S001-S999
|
||||
/// </summary>
|
||||
Seed,
|
||||
@@ -83,10 +83,11 @@ public static class MigrationCategoryExtensions
|
||||
/// Returns true if this migration should run automatically at startup.
|
||||
/// </summary>
|
||||
public static bool IsAutomatic(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Startup or MigrationCategory.Seed;
|
||||
category is MigrationCategory.Startup;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool RequiresManualExecution(this MigrationCategory category) =>
|
||||
category is MigrationCategory.Release or MigrationCategory.Data;
|
||||
|
||||
@@ -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
|
||||
/// </remarks>
|
||||
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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -250,4 +253,3 @@ public sealed class ValkeyTestSession : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<AiAttestationService>.Instance);
|
||||
_service = new AiAttestationService(
|
||||
_timeProvider,
|
||||
new InMemoryAiAttestationStore(NullLogger<InMemoryAiAttestationStore>.Instance),
|
||||
NullLogger<AiAttestationService>.Instance);
|
||||
}
|
||||
|
||||
private static AiRunAttestation CreateSampleRunAttestation()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MigrationRunner>.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()
|
||||
{
|
||||
|
||||
@@ -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<MigrationRunner>.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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user