Refactor code structure for improved readability and maintainability; optimize performance in key functions.
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
internal sealed class SbomLedgerService : ISbomLedgerService
|
||||
{
|
||||
private readonly ISbomLedgerRepository _repository;
|
||||
private readonly IClock _clock;
|
||||
private readonly SbomLedgerOptions _options;
|
||||
|
||||
public SbomLedgerService(ISbomLedgerRepository repository, IClock clock, IOptions<SbomLedgerOptions> options)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_options = options?.Value ?? new SbomLedgerOptions();
|
||||
}
|
||||
|
||||
public async Task<SbomLedgerVersion> AddVersionAsync(SbomLedgerSubmission submission, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(submission);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var artifact = submission.ArtifactRef.Trim();
|
||||
var chainId = await _repository.GetChainIdAsync(artifact, cancellationToken).ConfigureAwait(false) ?? Guid.NewGuid();
|
||||
var existing = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var sequence = existing.Count + 1;
|
||||
var versionId = Guid.NewGuid();
|
||||
var createdAt = _clock.UtcNow;
|
||||
|
||||
SbomLedgerVersion? parent = null;
|
||||
if (submission.ParentVersionId.HasValue)
|
||||
{
|
||||
parent = await _repository.GetVersionAsync(submission.ParentVersionId.Value, cancellationToken).ConfigureAwait(false);
|
||||
if (parent is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Parent version '{submission.ParentVersionId}' was not found.");
|
||||
}
|
||||
}
|
||||
|
||||
var version = new SbomLedgerVersion
|
||||
{
|
||||
VersionId = versionId,
|
||||
ChainId = chainId,
|
||||
ArtifactRef = artifact,
|
||||
SequenceNumber = sequence,
|
||||
Digest = submission.Digest,
|
||||
Format = submission.Format,
|
||||
FormatVersion = submission.FormatVersion,
|
||||
Source = submission.Source,
|
||||
CreatedAtUtc = createdAt,
|
||||
Provenance = submission.Provenance,
|
||||
ParentVersionId = parent?.VersionId,
|
||||
ParentDigest = parent?.Digest,
|
||||
Components = submission.Components
|
||||
};
|
||||
|
||||
await _repository.AddVersionAsync(version, cancellationToken).ConfigureAwait(false);
|
||||
await _repository.AddAuditAsync(
|
||||
new SbomLedgerAuditEntry(artifact, versionId, "created", createdAt, $"format={submission.Format}"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public async Task<SbomVersionHistoryResult?> GetHistoryAsync(string artifactRef, int limit, int offset, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artifact = artifactRef.Trim();
|
||||
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var ordered = versions
|
||||
.OrderByDescending(v => v.SequenceNumber)
|
||||
.ThenByDescending(v => v.CreatedAtUtc)
|
||||
.ToList();
|
||||
|
||||
var page = ordered
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(v => new SbomVersionHistoryItem(
|
||||
v.VersionId,
|
||||
v.SequenceNumber,
|
||||
v.Digest,
|
||||
v.Format,
|
||||
v.FormatVersion,
|
||||
v.Source,
|
||||
v.CreatedAtUtc,
|
||||
v.ParentVersionId,
|
||||
v.ParentDigest,
|
||||
v.Components.Count))
|
||||
.ToList();
|
||||
|
||||
var nextCursor = offset + limit < ordered.Count
|
||||
? (offset + limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var chainId = versions.First().ChainId;
|
||||
return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor);
|
||||
}
|
||||
|
||||
public async Task<SbomTemporalQueryResult?> GetAtTimeAsync(string artifactRef, DateTimeOffset atUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artifact = artifactRef.Trim();
|
||||
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = versions
|
||||
.Where(v => v.CreatedAtUtc <= atUtc)
|
||||
.OrderByDescending(v => v.CreatedAtUtc)
|
||||
.ThenByDescending(v => v.SequenceNumber)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
return new SbomTemporalQueryResult(artifact, null);
|
||||
}
|
||||
|
||||
return new SbomTemporalQueryResult(
|
||||
artifact,
|
||||
new SbomVersionHistoryItem(
|
||||
match.VersionId,
|
||||
match.SequenceNumber,
|
||||
match.Digest,
|
||||
match.Format,
|
||||
match.FormatVersion,
|
||||
match.Source,
|
||||
match.CreatedAtUtc,
|
||||
match.ParentVersionId,
|
||||
match.ParentDigest,
|
||||
match.Components.Count));
|
||||
}
|
||||
|
||||
public async Task<SbomVersionHistoryResult?> GetRangeAsync(string artifactRef, DateTimeOffset startUtc, DateTimeOffset endUtc, int limit, int offset, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artifact = artifactRef.Trim();
|
||||
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var filtered = versions
|
||||
.Where(v => v.CreatedAtUtc >= startUtc && v.CreatedAtUtc <= endUtc)
|
||||
.OrderByDescending(v => v.CreatedAtUtc)
|
||||
.ThenByDescending(v => v.SequenceNumber)
|
||||
.ToList();
|
||||
|
||||
var page = filtered
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(v => new SbomVersionHistoryItem(
|
||||
v.VersionId,
|
||||
v.SequenceNumber,
|
||||
v.Digest,
|
||||
v.Format,
|
||||
v.FormatVersion,
|
||||
v.Source,
|
||||
v.CreatedAtUtc,
|
||||
v.ParentVersionId,
|
||||
v.ParentDigest,
|
||||
v.Components.Count))
|
||||
.ToList();
|
||||
|
||||
var nextCursor = offset + limit < filtered.Count
|
||||
? (offset + limit).ToString(CultureInfo.InvariantCulture)
|
||||
: null;
|
||||
|
||||
var chainId = versions.First().ChainId;
|
||||
return new SbomVersionHistoryResult(artifact, chainId, page, nextCursor);
|
||||
}
|
||||
|
||||
public async Task<SbomDiffResult?> DiffAsync(Guid beforeVersionId, Guid afterVersionId, CancellationToken cancellationToken)
|
||||
{
|
||||
var before = await _repository.GetVersionAsync(beforeVersionId, cancellationToken).ConfigureAwait(false);
|
||||
var after = await _repository.GetVersionAsync(afterVersionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (before is null || after is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var beforeMap = BuildComponentMap(before.Components);
|
||||
var afterMap = BuildComponentMap(after.Components);
|
||||
|
||||
var added = new List<SbomDiffComponent>();
|
||||
var removed = new List<SbomDiffComponent>();
|
||||
var versionChanged = new List<SbomVersionChange>();
|
||||
var licenseChanged = new List<SbomLicenseChange>();
|
||||
|
||||
foreach (var (key, component) in afterMap)
|
||||
{
|
||||
if (!beforeMap.TryGetValue(key, out var beforeComponent))
|
||||
{
|
||||
added.Add(ToDiffComponent(component));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(component.Version, beforeComponent.Version, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
versionChanged.Add(new SbomVersionChange(
|
||||
key,
|
||||
component.Name,
|
||||
component.Purl,
|
||||
beforeComponent.Version,
|
||||
component.Version));
|
||||
}
|
||||
|
||||
if (!string.Equals(component.License, beforeComponent.License, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
licenseChanged.Add(new SbomLicenseChange(
|
||||
key,
|
||||
component.Name,
|
||||
component.Purl,
|
||||
beforeComponent.License,
|
||||
component.License));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var (key, component) in beforeMap)
|
||||
{
|
||||
if (!afterMap.ContainsKey(key))
|
||||
{
|
||||
removed.Add(ToDiffComponent(component));
|
||||
}
|
||||
}
|
||||
|
||||
added = added.OrderBy(c => c.Key, StringComparer.Ordinal).ToList();
|
||||
removed = removed.OrderBy(c => c.Key, StringComparer.Ordinal).ToList();
|
||||
versionChanged = versionChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList();
|
||||
licenseChanged = licenseChanged.OrderBy(c => c.Key, StringComparer.Ordinal).ToList();
|
||||
|
||||
return new SbomDiffResult
|
||||
{
|
||||
BeforeVersionId = beforeVersionId,
|
||||
AfterVersionId = afterVersionId,
|
||||
Added = added,
|
||||
Removed = removed,
|
||||
VersionChanged = versionChanged,
|
||||
LicenseChanged = licenseChanged,
|
||||
Summary = new SbomDiffSummary(added.Count, removed.Count, versionChanged.Count, licenseChanged.Count)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SbomLineageResult?> GetLineageAsync(string artifactRef, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artifactRef))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artifact = artifactRef.Trim();
|
||||
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var nodes = versions
|
||||
.OrderBy(v => v.SequenceNumber)
|
||||
.ThenBy(v => v.CreatedAtUtc)
|
||||
.Select(v => new SbomLineageNode(v.VersionId, v.SequenceNumber, v.Digest, v.Source, v.CreatedAtUtc))
|
||||
.ToList();
|
||||
|
||||
var edges = new List<SbomLineageEdge>();
|
||||
|
||||
edges.AddRange(versions
|
||||
.Where(v => v.ParentVersionId.HasValue)
|
||||
.Select(v => new SbomLineageEdge(v.ParentVersionId!.Value, v.VersionId, SbomLineageRelationships.Parent)));
|
||||
|
||||
var buildEdges = versions
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v.Provenance?.CiContext?.BuildId))
|
||||
.GroupBy(v => v.Provenance!.CiContext!.BuildId!.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.SelectMany(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(v => v.SequenceNumber)
|
||||
.ThenBy(v => v.CreatedAtUtc)
|
||||
.ToList();
|
||||
|
||||
var groupEdges = new List<SbomLineageEdge>();
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
groupEdges.Add(new SbomLineageEdge(
|
||||
ordered[i - 1].VersionId,
|
||||
ordered[i].VersionId,
|
||||
SbomLineageRelationships.Build));
|
||||
}
|
||||
|
||||
return groupEdges;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
edges.AddRange(buildEdges);
|
||||
|
||||
var orderedEdges = edges
|
||||
.GroupBy(e => new { e.FromVersionId, e.ToVersionId, e.Relationship })
|
||||
.Select(g => g.First())
|
||||
.OrderBy(e => e.FromVersionId)
|
||||
.ThenBy(e => e.ToVersionId)
|
||||
.ThenBy(e => e.Relationship, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new SbomLineageResult(artifact, versions[0].ChainId, nodes, orderedEdges);
|
||||
}
|
||||
|
||||
public async Task<SbomRetentionResult> ApplyRetentionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var artifacts = await _repository.ListArtifactsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var messages = new List<string>();
|
||||
var totalPruned = 0;
|
||||
var chainsTouched = 0;
|
||||
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
var versions = await _repository.GetVersionsAsync(artifact, cancellationToken).ConfigureAwait(false);
|
||||
if (versions.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var prunable = ApplyRetentionPolicy(versions);
|
||||
if (prunable.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pruned = await _repository.RemoveVersionsAsync(artifact, prunable.Select(v => v.VersionId).ToList(), cancellationToken).ConfigureAwait(false);
|
||||
totalPruned += pruned;
|
||||
chainsTouched++;
|
||||
|
||||
foreach (var version in prunable)
|
||||
{
|
||||
await _repository.AddAuditAsync(
|
||||
new SbomLedgerAuditEntry(artifact, version.VersionId, "retention_prune", _clock.UtcNow, $"sequence={version.SequenceNumber}"),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
messages.Add($"Pruned {pruned} versions for {artifact}.");
|
||||
}
|
||||
|
||||
return new SbomRetentionResult(totalPruned, chainsTouched, messages);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomLedgerAuditEntry>> GetAuditAsync(string artifactRef, CancellationToken cancellationToken)
|
||||
{
|
||||
return _repository.GetAuditAsync(artifactRef.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomAnalysisJob>> ListAnalysisJobsAsync(string artifactRef, CancellationToken cancellationToken)
|
||||
{
|
||||
return _repository.ListAnalysisJobsAsync(artifactRef.Trim(), cancellationToken);
|
||||
}
|
||||
|
||||
private List<SbomLedgerVersion> ApplyRetentionPolicy(IReadOnlyList<SbomLedgerVersion> versions)
|
||||
{
|
||||
var ordered = versions.OrderByDescending(v => v.CreatedAtUtc).ThenByDescending(v => v.SequenceNumber).ToList();
|
||||
var keep = new HashSet<Guid>();
|
||||
|
||||
for (var i = 0; i < Math.Min(_options.MinVersionsToKeep, ordered.Count); i++)
|
||||
{
|
||||
keep.Add(ordered[i].VersionId);
|
||||
}
|
||||
|
||||
var prunable = new List<SbomLedgerVersion>();
|
||||
|
||||
if (_options.MaxVersionsPerArtifact > 0 && ordered.Count > _options.MaxVersionsPerArtifact)
|
||||
{
|
||||
var toPrune = ordered
|
||||
.Skip(_options.MaxVersionsPerArtifact)
|
||||
.Where(v => !keep.Contains(v.VersionId))
|
||||
.ToList();
|
||||
prunable.AddRange(toPrune);
|
||||
}
|
||||
|
||||
if (_options.MaxAgeDays > 0)
|
||||
{
|
||||
var threshold = _clock.UtcNow.AddDays(-_options.MaxAgeDays);
|
||||
foreach (var version in ordered.Where(v => v.CreatedAtUtc < threshold))
|
||||
{
|
||||
if (!keep.Contains(version.VersionId))
|
||||
{
|
||||
prunable.Add(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prunable
|
||||
.GroupBy(v => v.VersionId)
|
||||
.Select(g => g.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, SbomNormalizedComponent> BuildComponentMap(IReadOnlyList<SbomNormalizedComponent> components)
|
||||
{
|
||||
var map = new Dictionary<string, SbomNormalizedComponent>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var component in components.OrderBy(c => c.Key, StringComparer.Ordinal))
|
||||
{
|
||||
if (!map.ContainsKey(component.Key))
|
||||
{
|
||||
map[component.Key] = component;
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static SbomDiffComponent ToDiffComponent(SbomNormalizedComponent component)
|
||||
=> new(component.Key, component.Name, component.Purl, component.Version, component.License);
|
||||
}
|
||||
Reference in New Issue
Block a user