Files
git.stella-ops.org/src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/Reindexing/EvidenceReindexService.cs
2026-02-01 21:37:40 +02:00

504 lines
17 KiB
C#

using StellaOps.EvidenceLocker.Core.Builders;
using StellaOps.EvidenceLocker.Core.Domain;
using StellaOps.EvidenceLocker.Core.Reindexing;
using StellaOps.EvidenceLocker.Core.Repositories;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace StellaOps.EvidenceLocker.Infrastructure.Reindexing;
public sealed class EvidenceReindexService : IEvidenceReindexService
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly IEvidenceBundleRepository _repository;
private readonly IMerkleTreeCalculator _merkleTreeCalculator;
private readonly TimeProvider _timeProvider;
public EvidenceReindexService(
IEvidenceBundleRepository repository,
IMerkleTreeCalculator merkleTreeCalculator,
TimeProvider timeProvider)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_merkleTreeCalculator = merkleTreeCalculator ?? throw new ArgumentNullException(nameof(merkleTreeCalculator));
_timeProvider = timeProvider ?? TimeProvider.System;
}
public async Task<ReindexResult> ReindexAsync(
ReindexOptions options,
IProgress<ReindexProgress> progress,
CancellationToken ct)
{
if (options.TenantId == default)
{
throw new ArgumentException("TenantId is required for reindex operations.", nameof(options));
}
if (options.BatchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(options.BatchSize), "BatchSize must be positive.");
}
var startedAt = _timeProvider.GetUtcNow();
var errors = new List<string>();
var processed = 0;
var reindexed = 0;
var failed = 0;
DateTimeOffset? cursorUpdatedAt = options.Since;
EvidenceBundleId? cursorBundleId = null;
while (true)
{
var batch = await _repository.GetBundlesForReindexAsync(
options.TenantId,
options.Since,
cursorUpdatedAt,
cursorBundleId,
options.BatchSize,
ct).ConfigureAwait(false);
if (batch.Count == 0)
{
break;
}
foreach (var details in batch)
{
processed++;
try
{
if (details.Signature is null)
{
throw new InvalidOperationException($"Missing signature for bundle {details.Bundle.Id.Value:D}.");
}
var manifest = DecodeManifest(details.Signature.Payload);
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
var rootHash = _merkleTreeCalculator.CalculateRootHash(
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}"));
if (!string.Equals(rootHash, details.Bundle.RootHash, StringComparison.OrdinalIgnoreCase))
{
reindexed++;
if (!options.DryRun)
{
await _repository.SetBundleAssemblyAsync(
details.Bundle.Id,
details.Bundle.TenantId,
details.Bundle.Status,
rootHash,
_timeProvider.GetUtcNow(),
ct).ConfigureAwait(false);
}
}
progress?.Report(new ReindexProgress
{
TotalBundles = processed,
BundlesProcessed = processed,
CurrentBundleId = details.Bundle.Id.Value.ToString("D"),
Message = options.DryRun ? "assessed" : "reindexed"
});
}
catch (Exception ex)
{
failed++;
errors.Add(ex.Message);
}
cursorUpdatedAt = details.Bundle.UpdatedAt;
cursorBundleId = details.Bundle.Id;
}
}
var completedAt = _timeProvider.GetUtcNow();
return new ReindexResult
{
TotalBundles = processed,
ReindexedBundles = reindexed,
FailedBundles = failed,
StartedAt = startedAt,
CompletedAt = completedAt,
Errors = errors
};
}
public Task<ContinuityVerificationResult> VerifyContinuityAsync(
TenantId tenantId,
string oldRoot,
string newRoot,
CancellationToken ct)
{
if (tenantId == default)
{
throw new ArgumentException("TenantId is required for continuity verification.", nameof(tenantId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(oldRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(newRoot);
return VerifyContinuityInternalAsync(tenantId, oldRoot, newRoot, ct);
}
public Task<RootCrossReferenceMap> GenerateCrossReferenceAsync(
TenantId tenantId,
DateTimeOffset since,
CancellationToken ct)
{
if (tenantId == default)
{
throw new ArgumentException("TenantId is required for cross-reference generation.", nameof(tenantId));
}
return GenerateCrossReferenceInternalAsync(tenantId, since, ct);
}
private static ManifestDocument DecodeManifest(string payload)
{
byte[] bytes;
try
{
bytes = Convert.FromBase64String(payload);
}
catch (FormatException ex)
{
throw new InvalidOperationException("Manifest payload is not valid base64.", ex);
}
try
{
return JsonSerializer.Deserialize<ManifestDocument>(bytes, SerializerOptions)
?? throw new InvalidOperationException("Manifest payload is empty.");
}
catch (JsonException ex)
{
throw new InvalidOperationException("Manifest payload is not valid JSON.", ex);
}
}
private sealed record ManifestDocument(
Guid BundleId,
Guid TenantId,
int Kind,
DateTimeOffset CreatedAt,
IDictionary<string, string>? Metadata,
ManifestEntryDocument[]? Entries);
private sealed record ManifestEntryDocument(
string Section,
string CanonicalPath,
string Sha256,
long SizeBytes,
string? MediaType,
IDictionary<string, string>? Attributes);
private async Task<RootCrossReferenceMap> GenerateCrossReferenceInternalAsync(
TenantId tenantId,
DateTimeOffset since,
CancellationToken ct)
{
var entries = new List<RootCrossReferenceEntry>();
var failed = 0;
DateTimeOffset? cursorUpdatedAt = since;
EvidenceBundleId? cursorBundleId = null;
while (true)
{
var batch = await _repository.GetBundlesForReindexAsync(
tenantId,
since,
cursorUpdatedAt,
cursorBundleId,
250,
ct).ConfigureAwait(false);
if (batch.Count == 0)
{
break;
}
foreach (var details in batch)
{
var bundleId = details.Bundle.Id.Value.ToString("D");
var oldRoot = details.Bundle.RootHash;
var evidenceCount = 0;
var verified = false;
var digestsPreserved = false;
var newRoot = string.Empty;
try
{
if (details.Signature is null)
{
throw new InvalidOperationException($"Missing signature for bundle {bundleId}.");
}
var manifest = DecodeManifest(details.Signature.Payload);
evidenceCount = manifest.Entries?.Length ?? 0;
newRoot = ComputeRootHash(manifest);
verified = true;
digestsPreserved = string.Equals(oldRoot, newRoot, StringComparison.OrdinalIgnoreCase);
}
catch
{
failed++;
}
if (verified)
{
entries.Add(new RootCrossReferenceEntry
{
BundleId = bundleId,
OldRoot = oldRoot,
NewRoot = newRoot,
EvidenceCount = evidenceCount,
Verified = verified,
DigestsPreserved = digestsPreserved
});
}
cursorUpdatedAt = details.Bundle.UpdatedAt;
cursorBundleId = details.Bundle.Id;
}
}
return new RootCrossReferenceMap
{
SchemaVersion = "1.0.0",
GeneratedAt = _timeProvider.GetUtcNow(),
Entries = entries,
Summary = new RootCrossReferenceSummary
{
TotalBundles = entries.Count + failed,
SuccessfulMigrations = entries.Count,
FailedMigrations = failed,
DigestsPreserved = entries.Count
}
};
}
private async Task<ContinuityVerificationResult> VerifyContinuityInternalAsync(
TenantId tenantId,
string oldRoot,
string newRoot,
CancellationToken ct)
{
var foundOldRoot = false;
var crossReferenceValid = false;
var recomputedMatchesOld = false;
DateTimeOffset? cursorUpdatedAt = null;
EvidenceBundleId? cursorBundleId = null;
while (true)
{
var batch = await _repository.GetBundlesForReindexAsync(
tenantId,
null,
cursorUpdatedAt,
cursorBundleId,
250,
ct).ConfigureAwait(false);
if (batch.Count == 0)
{
break;
}
foreach (var details in batch)
{
if (!string.Equals(details.Bundle.RootHash, oldRoot, StringComparison.OrdinalIgnoreCase))
{
cursorUpdatedAt = details.Bundle.UpdatedAt;
cursorBundleId = details.Bundle.Id;
continue;
}
foundOldRoot = true;
if (details.Signature is not null)
{
var manifest = DecodeManifest(details.Signature.Payload);
var recomputed = ComputeRootHash(manifest);
recomputedMatchesOld = string.Equals(recomputed, oldRoot, StringComparison.OrdinalIgnoreCase);
if (string.Equals(recomputed, newRoot, StringComparison.OrdinalIgnoreCase))
{
crossReferenceValid = true;
break;
}
}
cursorUpdatedAt = details.Bundle.UpdatedAt;
cursorBundleId = details.Bundle.Id;
}
if (crossReferenceValid)
{
break;
}
}
var notes = !foundOldRoot
? "Old root not found in evidence bundles."
: crossReferenceValid
? null
: recomputedMatchesOld
? "Old root recomputed successfully but does not match the provided new root."
: "Old root found but manifest recomputation did not match the stored root.";
return new ContinuityVerificationResult
{
OldRootValid = foundOldRoot,
NewRootValid = crossReferenceValid,
AllEvidencePreserved = crossReferenceValid,
CrossReferenceValid = crossReferenceValid,
OldProofsStillValid = foundOldRoot && recomputedMatchesOld,
Notes = notes
};
}
private string ComputeRootHash(ManifestDocument manifest)
{
var entries = manifest.Entries ?? Array.Empty<ManifestEntryDocument>();
return _merkleTreeCalculator.CalculateRootHash(
entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256.ToLowerInvariant()}")
);
}
// In-memory checkpoint storage (production would use persistent storage)
private readonly Dictionary<string, ReindexCheckpoint> _checkpoints = new();
public async Task<ReindexCheckpoint> CreateCheckpointAsync(
TenantId tenantId,
string checkpointName,
CancellationToken ct)
{
if (tenantId == default)
{
throw new ArgumentException("TenantId is required for checkpoint creation.", nameof(tenantId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointName);
var snapshots = new List<CheckpointBundleSnapshot>();
DateTimeOffset? cursorUpdatedAt = null;
EvidenceBundleId? cursorBundleId = null;
// Capture current state of all bundles
while (true)
{
var batch = await _repository.GetBundlesForReindexAsync(
tenantId,
null,
cursorUpdatedAt,
cursorBundleId,
250,
ct).ConfigureAwait(false);
if (batch.Count == 0)
{
break;
}
foreach (var details in batch)
{
snapshots.Add(new CheckpointBundleSnapshot
{
BundleId = details.Bundle.Id.Value.ToString("D"),
RootHash = details.Bundle.RootHash,
CapturedAt = _timeProvider.GetUtcNow()
});
cursorUpdatedAt = details.Bundle.UpdatedAt;
cursorBundleId = details.Bundle.Id;
}
}
var checkpointId = $"ckpt-{Guid.NewGuid():N}";
var checkpoint = new ReindexCheckpoint
{
CheckpointId = checkpointId,
Name = checkpointName,
CreatedAt = _timeProvider.GetUtcNow(),
BundleCount = snapshots.Count,
SchemaVersion = "1.0.0",
Snapshots = snapshots
};
_checkpoints[checkpointId] = checkpoint;
return checkpoint;
}
public async Task<RollbackResult> RollbackToCheckpointAsync(
TenantId tenantId,
string checkpointId,
CancellationToken ct)
{
if (tenantId == default)
{
throw new ArgumentException("TenantId is required for rollback.", nameof(tenantId));
}
ArgumentException.ThrowIfNullOrWhiteSpace(checkpointId);
if (!_checkpoints.TryGetValue(checkpointId, out var checkpoint))
{
throw new InvalidOperationException($"Checkpoint '{checkpointId}' not found.");
}
var startedAt = _timeProvider.GetUtcNow();
var restored = 0;
var failed = 0;
var errors = new List<string>();
foreach (var snapshot in checkpoint.Snapshots)
{
try
{
var bundleId = EvidenceBundleId.FromGuid(Guid.Parse(snapshot.BundleId));
await _repository.SetBundleAssemblyAsync(
bundleId,
tenantId,
EvidenceBundleStatus.Sealed,
snapshot.RootHash,
_timeProvider.GetUtcNow(),
ct).ConfigureAwait(false);
restored++;
}
catch (Exception ex)
{
failed++;
errors.Add($"Failed to restore bundle {snapshot.BundleId}: {ex.Message}");
}
}
return new RollbackResult
{
Success = failed == 0,
BundlesRestored = restored,
BundlesFailed = failed,
StartedAt = startedAt,
CompletedAt = _timeProvider.GetUtcNow(),
Errors = errors
};
}
public Task<IReadOnlyList<ReindexCheckpoint>> ListCheckpointsAsync(
TenantId tenantId,
CancellationToken ct)
{
if (tenantId == default)
{
throw new ArgumentException("TenantId is required for listing checkpoints.", nameof(tenantId));
}
// Return checkpoints ordered by creation time (newest first)
var checkpoints = _checkpoints.Values
.OrderByDescending(c => c.CreatedAt)
.ToList();
return Task.FromResult<IReadOnlyList<ReindexCheckpoint>>(checkpoints);
}
}