This commit is contained in:
master
2026-02-04 19:59:20 +02:00
parent 557feefdc3
commit 5548cf83bf
1479 changed files with 53557 additions and 40339 deletions

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Evidence.Budgets;
public sealed record BudgetCheckResult
{
public required bool IsWithinBudget { get; init; }
public IReadOnlyList<string> 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 };
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Evidence.Budgets;
public enum BudgetExceededAction
{
/// <summary>Log warning but continue.</summary>
Warn,
/// <summary>Block the operation.</summary>
Block,
/// <summary>Automatically prune lowest priority evidence.</summary>
AutoPrune
}

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Evidence.Budgets;
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<EvidenceType, TypeBudgetStatus> 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; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Evidence.Budgets;
public enum CompressionLevel
{
None,
Fast,
Optimal,
Maximum
}

View File

@@ -51,69 +51,3 @@ public sealed record EvidenceBudget
}
};
}
public enum EvidenceType
{
Verdict,
PolicyTrace,
CallGraph,
RuntimeCapture,
Sbom,
Vex,
Attestation,
PathWitness,
Advisory
}
public enum RetentionTier
{
/// <summary>Immediately accessible, highest cost.</summary>
Hot,
/// <summary>Quick retrieval, moderate cost.</summary>
Warm,
/// <summary>Delayed retrieval, lower cost.</summary>
Cold,
/// <summary>Long-term storage, lowest cost.</summary>
Archive
}
public sealed record RetentionPolicy
{
/// <summary>
/// How long evidence stays in this tier.
/// </summary>
public required TimeSpan Duration { get; init; }
/// <summary>
/// Compression algorithm for this tier.
/// </summary>
public CompressionLevel Compression { get; init; } = CompressionLevel.None;
/// <summary>
/// Whether to deduplicate within this tier.
/// </summary>
public bool Deduplicate { get; init; } = true;
}
public enum CompressionLevel
{
None,
Fast,
Optimal,
Maximum
}
public enum BudgetExceededAction
{
/// <summary>Log warning but continue.</summary>
Warn,
/// <summary>Block the operation.</summary>
Block,
/// <summary>Automatically prune lowest priority evidence.</summary>
AutoPrune
}

View File

@@ -0,0 +1,71 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Evidence.Budgets;
public sealed partial class EvidenceBudgetService
{
public async Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
if (usage.TotalBytes <= targetBytes)
{
return PruneResult.NoPruningNeeded();
}
var bytesToPrune = usage.TotalBytes - targetBytes;
var pruned = new List<PrunedItem>();
// Get all evidence items, sorted by pruning priority
var items = await _repository.GetByScanIdAsync(scanId, ct).ConfigureAwait(false);
var candidates = items
.Where(i => !budget.AlwaysPreserve.Contains(i.Type))
.OrderBy(i => GetPrunePriority(i))
.ThenBy(i => i.CreatedAt.UtcDateTime.Ticks)
.ThenBy(i => i.Id)
.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).ConfigureAwait(false);
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
};
}
}

View File

@@ -0,0 +1,30 @@
namespace StellaOps.Evidence.Budgets;
public sealed partial class EvidenceBudgetService
{
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
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
})
};
}
}

View File

@@ -0,0 +1,22 @@
namespace StellaOps.Evidence.Budgets;
public sealed partial class EvidenceBudgetService
{
private async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
{
// Implementation to calculate current usage from repository
var items = await _repository.GetByScanIdAsync(scanId, ct).ConfigureAwait(false);
var totalBytes = items.Sum(i => i.SizeBytes);
var byType = items
.GroupBy(i => i.Type)
.OrderBy(g => g.Key)
.ToDictionary(g => g.Key, g => g.Sum(i => i.SizeBytes));
return new UsageStats
{
TotalBytes = totalBytes,
ByType = byType
};
}
}

View File

@@ -1,18 +1,10 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Globalization;
namespace StellaOps.Evidence.Budgets;
public interface IEvidenceBudgetService
{
Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct);
Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct);
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
}
public sealed class EvidenceBudgetService : IEvidenceBudgetService
public sealed partial class EvidenceBudgetService : IEvidenceBudgetService
{
private readonly IEvidenceRepository _repository;
private readonly IOptionsMonitor<EvidenceBudget> _options;
@@ -32,7 +24,7 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
{
ArgumentNullException.ThrowIfNull(item);
var budget = _options.CurrentValue;
var currentUsage = await GetCurrentUsageAsync(scanId, ct);
var currentUsage = await GetCurrentUsageAsync(scanId, ct).ConfigureAwait(false);
var issues = new List<string>();
@@ -72,182 +64,4 @@ public sealed class EvidenceBudgetService : IEvidenceBudgetService
BytesToFree = Math.Max(0, projectedTotal - budget.MaxScanSizeBytes)
};
}
public async Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = await GetCurrentUsageAsync(scanId, ct);
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<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct)
{
var budget = _options.CurrentValue;
var usage = await GetCurrentUsageAsync(scanId, ct);
if (usage.TotalBytes <= targetBytes)
{
return PruneResult.NoPruningNeeded();
}
var bytesToPrune = usage.TotalBytes - targetBytes;
var pruned = new List<PrunedItem>();
// 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))
.ThenBy(i => i.CreatedAt.UtcDateTime.Ticks)
.ThenBy(i => i.Id)
.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 async Task<UsageStats> GetCurrentUsageAsync(Guid scanId, CancellationToken ct)
{
// Implementation to calculate current usage from repository
var items = await _repository.GetByScanIdAsync(scanId, ct);
var totalBytes = items.Sum(i => i.SizeBytes);
var byType = items
.GroupBy(i => i.Type)
.OrderBy(g => g.Key)
.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<string> 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<EvidenceType, TypeBudgetStatus> 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<PrunedItem> 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<EvidenceType, long> ByType { get; init; } = new Dictionary<EvidenceType, long>();
}
// Supporting interfaces and types
public interface IEvidenceRepository
{
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAsync(Guid scanId, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAndTypeAsync(Guid scanId, EvidenceType type, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> 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; }
}

View File

@@ -0,0 +1,12 @@
namespace StellaOps.Evidence.Budgets;
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; }
}

View File

@@ -0,0 +1,14 @@
namespace StellaOps.Evidence.Budgets;
public enum EvidenceType
{
Verdict,
PolicyTrace,
CallGraph,
RuntimeCapture,
Sbom,
Vex,
Attestation,
PathWitness,
Advisory
}

View File

@@ -0,0 +1,8 @@
namespace StellaOps.Evidence.Budgets;
public interface IEvidenceBudgetService
{
Task<BudgetCheckResult> CheckBudgetAsync(Guid scanId, EvidenceItem item, CancellationToken ct);
Task<BudgetStatus> GetBudgetStatusAsync(Guid scanId, CancellationToken ct);
Task<PruneResult> PruneToFitAsync(Guid scanId, long targetBytes, CancellationToken ct);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Evidence.Budgets;
public interface IEvidenceRepository
{
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAsync(Guid scanId, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> GetByScanIdAndTypeAsync(Guid scanId, EvidenceType type, CancellationToken ct);
Task<IReadOnlyList<EvidenceItem>> GetOlderThanAsync(RetentionTier tier, DateTimeOffset cutoff, CancellationToken ct);
Task MoveToTierAsync(Guid itemId, RetentionTier tier, CancellationToken ct);
Task UpdateContentAsync(Guid itemId, byte[] content, CancellationToken ct);
}

View File

@@ -0,0 +1,13 @@
namespace StellaOps.Evidence.Budgets;
public sealed record PruneResult
{
public required bool Success { get; init; }
public long BytesPruned { get; init; }
public IReadOnlyList<PrunedItem> 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);

View File

@@ -0,0 +1,19 @@
namespace StellaOps.Evidence.Budgets;
public sealed record RetentionPolicy
{
/// <summary>
/// How long evidence stays in this tier.
/// </summary>
public required TimeSpan Duration { get; init; }
/// <summary>
/// Compression algorithm for this tier.
/// </summary>
public CompressionLevel Compression { get; init; } = CompressionLevel.None;
/// <summary>
/// Whether to deduplicate within this tier.
/// </summary>
public bool Deduplicate { get; init; } = true;
}

View File

@@ -0,0 +1,16 @@
namespace StellaOps.Evidence.Budgets;
public enum RetentionTier
{
/// <summary>Immediately accessible, highest cost.</summary>
Hot,
/// <summary>Quick retrieval, moderate cost.</summary>
Warm,
/// <summary>Delayed retrieval, lower cost.</summary>
Cold,
/// <summary>Long-term storage, lowest cost.</summary>
Archive
}

View File

@@ -0,0 +1,7 @@
namespace StellaOps.Evidence.Budgets;
public sealed record UsageStats
{
public long TotalBytes { get; init; }
public IReadOnlyDictionary<EvidenceType, long> ByType { get; init; } = new Dictionary<EvidenceType, long>();
}