Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
673 lines
23 KiB
C#
673 lines
23 KiB
C#
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.IO;
|
|
using System.IO.Enumeration;
|
|
using System.Reflection;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Scheduler.Models;
|
|
|
|
namespace StellaOps.Scheduler.ImpactIndex;
|
|
|
|
/// <summary>
|
|
/// Fixture-backed implementation of <see cref="IImpactIndex"/> used while the real index is under construction.
|
|
/// </summary>
|
|
public sealed class FixtureImpactIndex : IImpactIndex
|
|
{
|
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
};
|
|
|
|
private readonly ImpactIndexStubOptions _options;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILogger<FixtureImpactIndex> _logger;
|
|
private readonly SemaphoreSlim _initializationLock = new(1, 1);
|
|
private FixtureIndexState? _state;
|
|
|
|
public FixtureImpactIndex(
|
|
ImpactIndexStubOptions options,
|
|
TimeProvider? timeProvider,
|
|
ILogger<FixtureImpactIndex> logger)
|
|
{
|
|
_options = options ?? throw new ArgumentNullException(nameof(options));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public async ValueTask<ImpactSet> ResolveByPurlsAsync(
|
|
IEnumerable<string> purls,
|
|
bool usageOnly,
|
|
Selector selector,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(purls);
|
|
ArgumentNullException.ThrowIfNull(selector);
|
|
|
|
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
|
|
var normalizedPurls = NormalizeKeys(purls);
|
|
|
|
if (normalizedPurls.Length == 0)
|
|
{
|
|
return CreateImpactSet(state, selector, Enumerable.Empty<FixtureMatch>(), usageOnly);
|
|
}
|
|
|
|
var matches = new List<FixtureMatch>();
|
|
foreach (var purl in normalizedPurls)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
if (!state.PurlIndex.TryGetValue(purl, out var componentMatches))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var component in componentMatches)
|
|
{
|
|
var usedByEntrypoint = component.Component.UsedByEntrypoint;
|
|
if (usageOnly && !usedByEntrypoint)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
matches.Add(new FixtureMatch(component.Image, usedByEntrypoint));
|
|
}
|
|
}
|
|
|
|
return CreateImpactSet(state, selector, matches, usageOnly);
|
|
}
|
|
|
|
public async ValueTask<ImpactSet> ResolveByVulnerabilitiesAsync(
|
|
IEnumerable<string> vulnerabilityIds,
|
|
bool usageOnly,
|
|
Selector selector,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(vulnerabilityIds);
|
|
ArgumentNullException.ThrowIfNull(selector);
|
|
|
|
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
// The stub does not maintain a vulnerability → purl projection, so we return an empty result.
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
{
|
|
var first = vulnerabilityIds.FirstOrDefault(static id => !string.IsNullOrWhiteSpace(id));
|
|
if (first is not null)
|
|
{
|
|
_logger.LogDebug(
|
|
"ImpactIndex stub received ResolveByVulnerabilitiesAsync for '{VulnerabilityId}' but mappings are not available.",
|
|
first);
|
|
}
|
|
}
|
|
|
|
return CreateImpactSet(state, selector, Enumerable.Empty<FixtureMatch>(), usageOnly);
|
|
}
|
|
|
|
public async ValueTask<ImpactSet> ResolveAllAsync(
|
|
Selector selector,
|
|
bool usageOnly,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(selector);
|
|
|
|
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var matches = state.ImagesByDigest.Values
|
|
.Select(image => new FixtureMatch(image, image.UsedByEntrypoint))
|
|
.Where(match => !usageOnly || match.UsedByEntrypoint);
|
|
|
|
return CreateImpactSet(state, selector, matches, usageOnly);
|
|
}
|
|
|
|
public ValueTask RemoveAsync(string imageDigest, CancellationToken cancellationToken = default)
|
|
{
|
|
// Fixture-backed index is immutable; removals are ignored.
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
public async ValueTask<ImpactIndexSnapshot> CreateSnapshotAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var state = await EnsureInitializedAsync(cancellationToken).ConfigureAwait(false);
|
|
|
|
var images = state.ImagesByDigest.Values
|
|
.OrderBy(image => image.Digest, StringComparer.OrdinalIgnoreCase)
|
|
.Select((image, index) => new ImpactImageRecord(
|
|
index,
|
|
"fixture",
|
|
image.Digest,
|
|
image.Registry,
|
|
image.Repository,
|
|
image.Namespaces,
|
|
image.Tags,
|
|
image.Labels,
|
|
image.GeneratedAt,
|
|
image.Components.Select(c => c.Purl).ToImmutableArray(),
|
|
image.Components.Where(c => c.UsedByEntrypoint).Select(c => c.Purl).ToImmutableArray()))
|
|
.ToImmutableArray();
|
|
|
|
var contains = images
|
|
.SelectMany(img => img.Components.Select(purl => (purl, img.ImageId)))
|
|
.GroupBy(pair => pair.purl, StringComparer.OrdinalIgnoreCase)
|
|
.ToImmutableDictionary(
|
|
g => g.Key,
|
|
g => g.Select(p => p.ImageId).Distinct().OrderBy(id => id).ToImmutableArray(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var usedBy = images
|
|
.SelectMany(img => img.EntrypointComponents.Select(purl => (purl, img.ImageId)))
|
|
.GroupBy(pair => pair.purl, StringComparer.OrdinalIgnoreCase)
|
|
.ToImmutableDictionary(
|
|
g => g.Key,
|
|
g => g.Select(p => p.ImageId).Distinct().OrderBy(id => id).ToImmutableArray(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
return new ImpactIndexSnapshot(
|
|
state.GeneratedAt,
|
|
state.SnapshotId,
|
|
images,
|
|
contains,
|
|
usedBy);
|
|
}
|
|
|
|
public ValueTask RestoreSnapshotAsync(ImpactIndexSnapshot snapshot, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(snapshot);
|
|
// Fixture index remains immutable; restoration is a no-op.
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
private async Task<FixtureIndexState> EnsureInitializedAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (_state is not null)
|
|
{
|
|
return _state;
|
|
}
|
|
|
|
await _initializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (_state is not null)
|
|
{
|
|
return _state;
|
|
}
|
|
|
|
var state = await LoadAsync(cancellationToken).ConfigureAwait(false);
|
|
_state = state;
|
|
_logger.LogInformation(
|
|
"ImpactIndex stub loaded {ImageCount} fixture images from {SourceDescription}.",
|
|
state.ImagesByDigest.Count,
|
|
state.SourceDescription);
|
|
return state;
|
|
}
|
|
finally
|
|
{
|
|
_initializationLock.Release();
|
|
}
|
|
}
|
|
|
|
private async Task<FixtureIndexState> LoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
var images = new List<FixtureImage>();
|
|
string? sourceDescription = null;
|
|
|
|
if (!string.IsNullOrWhiteSpace(_options.FixtureDirectory))
|
|
{
|
|
var directory = ResolveDirectoryPath(_options.FixtureDirectory!);
|
|
if (Directory.Exists(directory))
|
|
{
|
|
images.AddRange(await LoadFromDirectoryAsync(directory, cancellationToken).ConfigureAwait(false));
|
|
sourceDescription = directory;
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning(
|
|
"ImpactIndex stub fixture directory '{Directory}' was not found. Falling back to embedded fixtures.",
|
|
directory);
|
|
}
|
|
}
|
|
|
|
if (images.Count == 0)
|
|
{
|
|
images.AddRange(await LoadFromResourcesAsync(cancellationToken).ConfigureAwait(false));
|
|
sourceDescription ??= "embedded:scheduler-impact-index-fixtures";
|
|
}
|
|
|
|
if (images.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("No BOM-Index fixtures were found for the ImpactIndex stub.");
|
|
}
|
|
|
|
return BuildState(images, sourceDescription!, _options.SnapshotId);
|
|
}
|
|
|
|
private static string ResolveDirectoryPath(string path)
|
|
{
|
|
if (Path.IsPathRooted(path))
|
|
{
|
|
return path;
|
|
}
|
|
|
|
var basePath = AppContext.BaseDirectory;
|
|
return Path.GetFullPath(Path.Combine(basePath, path));
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<FixtureImage>> LoadFromDirectoryAsync(
|
|
string directory,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var results = new List<FixtureImage>();
|
|
|
|
foreach (var file in Directory.EnumerateFiles(directory, "bom-index.json", SearchOption.AllDirectories)
|
|
.OrderBy(static file => file, StringComparer.Ordinal))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
await using var stream = File.OpenRead(file);
|
|
var document = await JsonSerializer.DeserializeAsync<BomIndexDocument>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (document is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
results.Add(CreateFixtureImage(document));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<FixtureImage>> LoadFromResourcesAsync(CancellationToken cancellationToken)
|
|
{
|
|
var assembly = typeof(FixtureImpactIndex).Assembly;
|
|
var resourceNames = assembly
|
|
.GetManifestResourceNames()
|
|
.Where(static name => name.EndsWith(".bom-index.json", StringComparison.OrdinalIgnoreCase))
|
|
.OrderBy(static name => name, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
var results = new List<FixtureImage>(resourceNames.Length);
|
|
|
|
foreach (var resourceName in resourceNames)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
|
|
await using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
if (stream is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var document = await JsonSerializer.DeserializeAsync<BomIndexDocument>(stream, SerializerOptions, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (document is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
results.Add(CreateFixtureImage(document));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
private static FixtureIndexState BuildState(
|
|
IReadOnlyList<FixtureImage> images,
|
|
string sourceDescription,
|
|
string snapshotId)
|
|
{
|
|
var imagesByDigest = images
|
|
.GroupBy(static image => image.Digest, StringComparer.OrdinalIgnoreCase)
|
|
.ToImmutableDictionary(
|
|
static group => group.Key,
|
|
static group => group
|
|
.OrderBy(static image => image.Repository, StringComparer.Ordinal)
|
|
.ThenBy(static image => image.Registry, StringComparer.Ordinal)
|
|
.ThenBy(static image => image.Tags.Length, Comparer<int>.Default)
|
|
.First(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var purlIndexBuilder = new Dictionary<string, List<FixtureComponentMatch>>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var image in images)
|
|
{
|
|
foreach (var component in image.Components)
|
|
{
|
|
if (!purlIndexBuilder.TryGetValue(component.Purl, out var list))
|
|
{
|
|
list = new List<FixtureComponentMatch>();
|
|
purlIndexBuilder[component.Purl] = list;
|
|
}
|
|
|
|
list.Add(new FixtureComponentMatch(image, component));
|
|
}
|
|
}
|
|
|
|
var purlIndex = purlIndexBuilder.ToImmutableDictionary(
|
|
static entry => entry.Key,
|
|
static entry => entry.Value
|
|
.OrderBy(static item => item.Image.Digest, StringComparer.Ordinal)
|
|
.Select(static item => new FixtureComponentMatch(item.Image, item.Component))
|
|
.ToImmutableArray(),
|
|
StringComparer.OrdinalIgnoreCase);
|
|
|
|
var generatedAt = images.Count == 0
|
|
? DateTimeOffset.UnixEpoch
|
|
: images.Max(static image => image.GeneratedAt);
|
|
|
|
return new FixtureIndexState(imagesByDigest, purlIndex, generatedAt, sourceDescription, snapshotId);
|
|
}
|
|
|
|
private ImpactSet CreateImpactSet(
|
|
FixtureIndexState state,
|
|
Selector selector,
|
|
IEnumerable<FixtureMatch> matches,
|
|
bool usageOnly)
|
|
{
|
|
var aggregated = new Dictionary<string, ImpactImageBuilder>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var match in matches)
|
|
{
|
|
if (!ImageMatchesSelector(match.Image, selector))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!aggregated.TryGetValue(match.Image.Digest, out var builder))
|
|
{
|
|
builder = new ImpactImageBuilder(match.Image);
|
|
aggregated[match.Image.Digest] = builder;
|
|
}
|
|
|
|
builder.MarkUsedByEntrypoint(match.UsedByEntrypoint);
|
|
}
|
|
|
|
var images = aggregated.Values
|
|
.Select(static builder => builder.Build())
|
|
.OrderBy(static image => image.ImageDigest, StringComparer.Ordinal)
|
|
.ToImmutableArray();
|
|
|
|
return new ImpactSet(
|
|
selector,
|
|
images,
|
|
usageOnly,
|
|
state.GeneratedAt == DateTimeOffset.UnixEpoch
|
|
? _timeProvider.GetUtcNow()
|
|
: state.GeneratedAt,
|
|
images.Length,
|
|
state.SnapshotId,
|
|
SchedulerSchemaVersions.ImpactSet);
|
|
}
|
|
|
|
private static bool ImageMatchesSelector(FixtureImage image, Selector selector)
|
|
{
|
|
if (selector is null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (selector.Digests.Length > 0 &&
|
|
!selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (selector.Repositories.Length > 0)
|
|
{
|
|
var repositoryMatch = selector.Repositories.Any(repo =>
|
|
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase));
|
|
|
|
if (!repositoryMatch)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (selector.Namespaces.Length > 0)
|
|
{
|
|
if (image.Namespaces.IsDefaultOrEmpty)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var namespaceMatch = selector.Namespaces.Any(namespaceId =>
|
|
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase));
|
|
|
|
if (!namespaceMatch)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (selector.IncludeTags.Length > 0)
|
|
{
|
|
if (image.Tags.IsDefaultOrEmpty)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var tagMatch = selector.IncludeTags.Any(pattern =>
|
|
MatchesAnyTag(image.Tags, pattern));
|
|
|
|
if (!tagMatch)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (selector.Labels.Length > 0)
|
|
{
|
|
if (image.Labels.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (var labelSelector in selector.Labels)
|
|
{
|
|
if (!image.Labels.TryGetValue(labelSelector.Key, out var value))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (labelSelector.Values.Length > 0 &&
|
|
!labelSelector.Values.Contains(value, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return selector.Scope switch
|
|
{
|
|
SelectorScope.ByDigest => selector.Digests.Length == 0
|
|
? true
|
|
: selector.Digests.Contains(image.Digest, StringComparer.OrdinalIgnoreCase),
|
|
SelectorScope.ByRepository => selector.Repositories.Length == 0
|
|
? true
|
|
: selector.Repositories.Any(repo =>
|
|
string.Equals(repo, image.Repository, StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(repo, $"{image.Registry}/{image.Repository}", StringComparison.OrdinalIgnoreCase)),
|
|
SelectorScope.ByNamespace => selector.Namespaces.Length == 0
|
|
? true
|
|
: !image.Namespaces.IsDefaultOrEmpty &&
|
|
selector.Namespaces.Any(namespaceId =>
|
|
image.Namespaces.Contains(namespaceId, StringComparer.OrdinalIgnoreCase)),
|
|
SelectorScope.ByLabels => selector.Labels.Length == 0
|
|
? true
|
|
: selector.Labels.All(label =>
|
|
image.Labels.TryGetValue(label.Key, out var value) &&
|
|
(label.Values.Length == 0 || label.Values.Contains(value, StringComparer.OrdinalIgnoreCase))),
|
|
_ => true,
|
|
};
|
|
}
|
|
|
|
private static bool MatchesAnyTag(ImmutableArray<string> tags, string pattern)
|
|
{
|
|
foreach (var tag in tags)
|
|
{
|
|
if (FileSystemName.MatchesSimpleExpression(pattern, tag, ignoreCase: true))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static FixtureImage CreateFixtureImage(BomIndexDocument document)
|
|
{
|
|
if (document.Image is null)
|
|
{
|
|
throw new InvalidOperationException("BOM-Index image metadata is required.");
|
|
}
|
|
|
|
var digest = Validation.EnsureDigestFormat(document.Image.Digest, "image.digest");
|
|
var (registry, repository) = SplitRepository(document.Image.Repository);
|
|
|
|
var tags = string.IsNullOrWhiteSpace(document.Image.Tag)
|
|
? ImmutableArray<string>.Empty
|
|
: ImmutableArray.Create(document.Image.Tag.Trim());
|
|
|
|
var components = (document.Components ?? Array.Empty<BomIndexComponent>())
|
|
.Where(static component => !string.IsNullOrWhiteSpace(component.Purl))
|
|
.Select(component => new FixtureComponent(
|
|
component.Purl!.Trim(),
|
|
component.Usage?.Any(static usage =>
|
|
usage.Equals("runtime", StringComparison.OrdinalIgnoreCase) ||
|
|
usage.Equals("usedByEntrypoint", StringComparison.OrdinalIgnoreCase)) == true))
|
|
.OrderBy(static component => component.Purl, StringComparer.OrdinalIgnoreCase)
|
|
.ToImmutableArray();
|
|
|
|
return new FixtureImage(
|
|
digest,
|
|
registry,
|
|
repository,
|
|
ImmutableArray<string>.Empty,
|
|
tags,
|
|
ImmutableSortedDictionary<string, string>.Empty.WithComparers(StringComparer.OrdinalIgnoreCase),
|
|
components,
|
|
document.GeneratedAt == default ? DateTimeOffset.UnixEpoch : document.GeneratedAt.ToUniversalTime(),
|
|
components.Any(static component => component.UsedByEntrypoint));
|
|
}
|
|
|
|
private static (string Registry, string Repository) SplitRepository(string repository)
|
|
{
|
|
var normalized = Validation.EnsureNotNullOrWhiteSpace(repository, nameof(repository));
|
|
var separatorIndex = normalized.IndexOf('/');
|
|
if (separatorIndex < 0)
|
|
{
|
|
return ("docker.io", normalized);
|
|
}
|
|
|
|
var registry = normalized[..separatorIndex];
|
|
var repo = normalized[(separatorIndex + 1)..];
|
|
if (string.IsNullOrWhiteSpace(repo))
|
|
{
|
|
throw new ArgumentException("Repository segment is required after registry.", nameof(repository));
|
|
}
|
|
|
|
return (registry.Trim(), repo.Trim());
|
|
}
|
|
|
|
private static string[] NormalizeKeys(IEnumerable<string> values)
|
|
{
|
|
return values
|
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(static value => value.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.ToArray();
|
|
}
|
|
|
|
private readonly record struct FixtureMatch(FixtureImage Image, bool UsedByEntrypoint);
|
|
|
|
private sealed record FixtureImage(
|
|
string Digest,
|
|
string Registry,
|
|
string Repository,
|
|
ImmutableArray<string> Namespaces,
|
|
ImmutableArray<string> Tags,
|
|
ImmutableSortedDictionary<string, string> Labels,
|
|
ImmutableArray<FixtureComponent> Components,
|
|
DateTimeOffset GeneratedAt,
|
|
bool UsedByEntrypoint);
|
|
|
|
private sealed record FixtureComponent(string Purl, bool UsedByEntrypoint);
|
|
|
|
private sealed record FixtureComponentMatch(FixtureImage Image, FixtureComponent Component);
|
|
|
|
private sealed record FixtureIndexState(
|
|
ImmutableDictionary<string, FixtureImage> ImagesByDigest,
|
|
ImmutableDictionary<string, ImmutableArray<FixtureComponentMatch>> PurlIndex,
|
|
DateTimeOffset GeneratedAt,
|
|
string SourceDescription,
|
|
string SnapshotId);
|
|
|
|
private sealed class ImpactImageBuilder
|
|
{
|
|
private readonly FixtureImage _image;
|
|
private bool _usedByEntrypoint;
|
|
|
|
public ImpactImageBuilder(FixtureImage image)
|
|
{
|
|
_image = image;
|
|
}
|
|
|
|
public void MarkUsedByEntrypoint(bool usedByEntrypoint)
|
|
{
|
|
_usedByEntrypoint |= usedByEntrypoint;
|
|
}
|
|
|
|
public ImpactImage Build()
|
|
{
|
|
return new ImpactImage(
|
|
_image.Digest,
|
|
_image.Registry,
|
|
_image.Repository,
|
|
_image.Namespaces,
|
|
_image.Tags,
|
|
_usedByEntrypoint,
|
|
_image.Labels);
|
|
}
|
|
}
|
|
|
|
private sealed record BomIndexDocument
|
|
{
|
|
[JsonPropertyName("schema")]
|
|
public string? Schema { get; init; }
|
|
|
|
[JsonPropertyName("image")]
|
|
public BomIndexImage? Image { get; init; }
|
|
|
|
[JsonPropertyName("generatedAt")]
|
|
public DateTimeOffset GeneratedAt { get; init; }
|
|
|
|
[JsonPropertyName("components")]
|
|
public IReadOnlyList<BomIndexComponent>? Components { get; init; }
|
|
}
|
|
|
|
private sealed record BomIndexImage
|
|
{
|
|
[JsonPropertyName("repository")]
|
|
public string Repository { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("digest")]
|
|
public string Digest { get; init; } = string.Empty;
|
|
|
|
[JsonPropertyName("tag")]
|
|
public string? Tag { get; init; }
|
|
}
|
|
|
|
private sealed record BomIndexComponent
|
|
{
|
|
[JsonPropertyName("purl")]
|
|
public string? Purl { get; init; }
|
|
|
|
[JsonPropertyName("usage")]
|
|
public IReadOnlyList<string>? Usage { get; init; }
|
|
}
|
|
}
|