using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace StellaOps.Evidence.Budgets; public interface IEvidenceBudgetService { BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item); BudgetStatus GetBudgetStatus(Guid scanId); Task PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct); } public sealed class EvidenceBudgetService : IEvidenceBudgetService { private readonly IEvidenceRepository _repository; private readonly IOptionsMonitor _options; private readonly ILogger _logger; public EvidenceBudgetService( IEvidenceRepository repository, IOptionsMonitor options, ILogger logger) { _repository = repository; _options = options; _logger = logger; } public BudgetCheckResult CheckBudget(Guid scanId, EvidenceItem item) { var budget = _options.CurrentValue; var currentUsage = GetCurrentUsage(scanId); var issues = new List(); // Check total budget var projectedTotal = currentUsage.TotalBytes + item.SizeBytes; if (projectedTotal > budget.MaxScanSizeBytes) { issues.Add($"Would exceed total budget: {projectedTotal:N0} > {budget.MaxScanSizeBytes:N0} bytes"); } // Check per-type budget if (budget.MaxPerType.TryGetValue(item.Type, out var typeLimit)) { var typeUsage = currentUsage.ByType.GetValueOrDefault(item.Type, 0); var projectedType = typeUsage + item.SizeBytes; if (projectedType > typeLimit) { issues.Add($"Would exceed {item.Type} budget: {projectedType:N0} > {typeLimit:N0} bytes"); } } if (issues.Count == 0) { return BudgetCheckResult.WithinBudget(); } return new BudgetCheckResult { IsWithinBudget = false, Issues = issues, RecommendedAction = budget.ExceededAction, CanAutoPrune = budget.ExceededAction == BudgetExceededAction.AutoPrune, BytesToFree = Math.Max(0, projectedTotal - budget.MaxScanSizeBytes) }; } public BudgetStatus GetBudgetStatus(Guid scanId) { var budget = _options.CurrentValue; var usage = GetCurrentUsage(scanId); return new BudgetStatus { ScanId = scanId, TotalBudgetBytes = budget.MaxScanSizeBytes, UsedBytes = usage.TotalBytes, RemainingBytes = Math.Max(0, budget.MaxScanSizeBytes - usage.TotalBytes), UtilizationPercent = (decimal)usage.TotalBytes / budget.MaxScanSizeBytes * 100, ByType = usage.ByType.ToDictionary( kvp => kvp.Key, kvp => new TypeBudgetStatus { Type = kvp.Key, UsedBytes = kvp.Value, LimitBytes = budget.MaxPerType.GetValueOrDefault(kvp.Key), UtilizationPercent = budget.MaxPerType.TryGetValue(kvp.Key, out var limit) ? (decimal)kvp.Value / limit * 100 : 0 }) }; } public async Task PruneToFitAsync( Guid scanId, long targetBytes, CancellationToken ct) { var budget = _options.CurrentValue; var usage = GetCurrentUsage(scanId); if (usage.TotalBytes <= targetBytes) { return PruneResult.NoPruningNeeded(); } var bytesToPrune = usage.TotalBytes - targetBytes; var pruned = new List(); // Get all evidence items, sorted by pruning priority var items = await _repository.GetByScanIdAsync(scanId, ct); var candidates = items .Where(i => !budget.AlwaysPreserve.Contains(i.Type)) .OrderBy(i => GetPrunePriority(i)) .ToList(); long prunedBytes = 0; foreach (var item in candidates) { if (prunedBytes >= bytesToPrune) break; // Move to archive tier or delete await _repository.MoveToTierAsync(item.Id, RetentionTier.Archive, ct); pruned.Add(new PrunedItem(item.Id, item.Type, item.SizeBytes)); prunedBytes += item.SizeBytes; } _logger.LogInformation( "Pruned {Count} items ({Bytes:N0} bytes) for scan {ScanId}", pruned.Count, prunedBytes, scanId); return new PruneResult { Success = prunedBytes >= bytesToPrune, BytesPruned = prunedBytes, ItemsPruned = pruned, BytesRemaining = usage.TotalBytes - prunedBytes }; } private static int GetPrunePriority(EvidenceItem item) { // Lower = prune first return item.Type switch { EvidenceType.RuntimeCapture => 1, EvidenceType.CallGraph => 2, EvidenceType.Advisory => 3, EvidenceType.PathWitness => 4, EvidenceType.PolicyTrace => 5, EvidenceType.Sbom => 6, EvidenceType.Vex => 7, EvidenceType.Attestation => 8, EvidenceType.Verdict => 9, // Never prune _ => 5 }; } private UsageStats GetCurrentUsage(Guid scanId) { // Implementation to calculate current usage from repository var items = _repository.GetByScanIdAsync(scanId, CancellationToken.None) .GetAwaiter().GetResult(); var totalBytes = items.Sum(i => i.SizeBytes); var byType = items .GroupBy(i => i.Type) .ToDictionary(g => g.Key, g => g.Sum(i => i.SizeBytes)); return new UsageStats { TotalBytes = totalBytes, ByType = byType }; } } public sealed record BudgetCheckResult { public required bool IsWithinBudget { get; init; } public IReadOnlyList Issues { get; init; } = []; public BudgetExceededAction RecommendedAction { get; init; } public bool CanAutoPrune { get; init; } public long BytesToFree { get; init; } public static BudgetCheckResult WithinBudget() => new() { IsWithinBudget = true }; } public sealed record BudgetStatus { public required Guid ScanId { get; init; } public required long TotalBudgetBytes { get; init; } public required long UsedBytes { get; init; } public required long RemainingBytes { get; init; } public required decimal UtilizationPercent { get; init; } public required IReadOnlyDictionary ByType { get; init; } } public sealed record TypeBudgetStatus { public required EvidenceType Type { get; init; } public required long UsedBytes { get; init; } public long? LimitBytes { get; init; } public decimal UtilizationPercent { get; init; } } public sealed record PruneResult { public required bool Success { get; init; } public long BytesPruned { get; init; } public IReadOnlyList ItemsPruned { get; init; } = []; public long BytesRemaining { get; init; } public static PruneResult NoPruningNeeded() => new() { Success = true }; } public sealed record PrunedItem(Guid ItemId, EvidenceType Type, long SizeBytes); public sealed record UsageStats { public long TotalBytes { get; init; } public IReadOnlyDictionary ByType { get; init; } = new Dictionary(); } // Supporting interfaces and types public interface IEvidenceRepository { Task> GetByScanIdAsync(Guid scanId, CancellationToken ct); Task> GetByScanIdAndTypeAsync(Guid scanId, EvidenceType type, CancellationToken ct); Task> GetOlderThanAsync(RetentionTier tier, DateTimeOffset cutoff, CancellationToken ct); Task MoveToTierAsync(Guid itemId, RetentionTier tier, CancellationToken ct); Task UpdateContentAsync(Guid itemId, byte[] content, CancellationToken ct); } public sealed record EvidenceItem { public required Guid Id { get; init; } public required Guid ScanId { get; init; } public required EvidenceType Type { get; init; } public required long SizeBytes { get; init; } public required RetentionTier Tier { get; init; } public required DateTimeOffset CreatedAt { get; init; } public string? ArchiveKey { get; init; } }