test fixes and new product advisories work
This commit is contained in:
@@ -98,7 +98,8 @@ public static class ExportAdapterServiceExtensions
|
||||
services.AddSingleton<IExportAdapter>(sp =>
|
||||
new MirrorAdapter(
|
||||
sp.GetRequiredService<ILogger<MirrorAdapter>>(),
|
||||
sp.GetRequiredService<ICryptoHash>()));
|
||||
sp.GetRequiredService<ICryptoHash>(),
|
||||
sp.GetService<IReferrerDiscoveryService>()));
|
||||
|
||||
// Register Trivy DB adapter
|
||||
services.AddSingleton<IExportAdapter>(sp =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
@@ -8,18 +9,40 @@ namespace StellaOps.ExportCenter.Core.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Export adapter that produces mirror bundles with filesystem layout, indexes, and manifests.
|
||||
/// Supports OCI referrer discovery to include SBOMs, attestations, and signatures linked to images.
|
||||
/// </summary>
|
||||
public sealed class MirrorAdapter : IExportAdapter
|
||||
{
|
||||
private const string DefaultBundleFileName = "export-mirror-bundle-v1.tgz";
|
||||
|
||||
// Regex to detect image references (registry/repo:tag or registry/repo@sha256:...)
|
||||
private static readonly Regex ImageReferencePattern = new(
|
||||
@"^(?<registry>[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9](:[0-9]+)?)/(?<repository>[a-zA-Z0-9][-a-zA-Z0-9._/]*)([:@])(?<reference>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.ExplicitCapture);
|
||||
|
||||
// Regex to detect digest format
|
||||
private static readonly Regex DigestPattern = new(
|
||||
@"^sha256:[a-fA-F0-9]{64}$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private readonly ILogger<MirrorAdapter> _logger;
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly IReferrerDiscoveryService _referrerDiscovery;
|
||||
|
||||
public MirrorAdapter(ILogger<MirrorAdapter> logger, ICryptoHash cryptoHash)
|
||||
/// <summary>
|
||||
/// Creates a new MirrorAdapter with referrer discovery support.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="cryptoHash">Crypto hash provider.</param>
|
||||
/// <param name="referrerDiscovery">Optional referrer discovery service. If null, referrer discovery is disabled.</param>
|
||||
public MirrorAdapter(
|
||||
ILogger<MirrorAdapter> logger,
|
||||
ICryptoHash cryptoHash,
|
||||
IReferrerDiscoveryService? referrerDiscovery = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
|
||||
_referrerDiscovery = referrerDiscovery ?? NullReferrerDiscoveryService.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -234,6 +257,7 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var dataSources = new List<MirrorBundleDataSource>();
|
||||
var discoveredImageRefs = new List<string>();
|
||||
|
||||
foreach (var item in context.Items)
|
||||
{
|
||||
@@ -299,6 +323,12 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
ContentHash = content.OriginalHash,
|
||||
ProcessedAt = context.TimeProvider.GetUtcNow()
|
||||
});
|
||||
|
||||
// Check if this item represents an image that might have referrers
|
||||
if (IsImageReference(item.SourceRef))
|
||||
{
|
||||
discoveredImageRefs.Add(item.SourceRef);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -307,9 +337,231 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
}
|
||||
}
|
||||
|
||||
// Discover and collect OCI referrer artifacts for all image references
|
||||
if (discoveredImageRefs.Count > 0)
|
||||
{
|
||||
var referrerSources = await DiscoverAndCollectReferrersAsync(
|
||||
discoveredImageRefs,
|
||||
tempDir,
|
||||
context,
|
||||
cancellationToken);
|
||||
|
||||
dataSources.AddRange(referrerSources);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {ReferrerCount} referrer artifacts for {ImageCount} images",
|
||||
referrerSources.Count,
|
||||
discoveredImageRefs.Count);
|
||||
}
|
||||
|
||||
return dataSources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Discovers OCI referrer artifacts for the given image references and collects their content.
|
||||
/// </summary>
|
||||
private async Task<List<MirrorBundleDataSource>> DiscoverAndCollectReferrersAsync(
|
||||
IReadOnlyList<string> imageReferences,
|
||||
string tempDir,
|
||||
ExportAdapterContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var referrerSources = new List<MirrorBundleDataSource>();
|
||||
var processedDigests = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Extract unique registries and probe capabilities at export start
|
||||
var uniqueRegistries = imageReferences
|
||||
.Select(ExtractRegistry)
|
||||
.Where(r => !string.IsNullOrEmpty(r))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (uniqueRegistries.Count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Probing {RegistryCount} registries for OCI referrer capabilities before export",
|
||||
uniqueRegistries.Count);
|
||||
|
||||
foreach (var registry in uniqueRegistries)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// Probe capabilities - this will log the result and cache it
|
||||
await _referrerDiscovery.ProbeRegistryCapabilitiesAsync(registry!, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var imageRef in imageReferences)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Discovering referrers for image: {ImageRef}", imageRef);
|
||||
|
||||
var discoveryResult = await _referrerDiscovery.DiscoverReferrersAsync(imageRef, cancellationToken);
|
||||
|
||||
if (!discoveryResult.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to discover referrers for {ImageRef}: {Error}",
|
||||
imageRef,
|
||||
discoveryResult.Error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (discoveryResult.Referrers.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No referrers found for image: {ImageRef}", imageRef);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Found {Count} referrers for {ImageRef} (API supported: {ApiSupported})",
|
||||
discoveryResult.Referrers.Count,
|
||||
imageRef,
|
||||
discoveryResult.SupportsReferrersApi);
|
||||
|
||||
// Process each referrer
|
||||
foreach (var referrer in discoveryResult.Referrers)
|
||||
{
|
||||
// Skip if we've already processed this digest (deduplication)
|
||||
if (!processedDigests.Add(referrer.Digest))
|
||||
{
|
||||
_logger.LogDebug("Skipping duplicate referrer: {Digest}", referrer.Digest);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine category for this referrer
|
||||
var category = referrer.Category;
|
||||
if (category is null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping referrer with unknown artifact type: {ArtifactType}",
|
||||
referrer.ArtifactType);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch referrer content
|
||||
var referrerContent = await FetchReferrerContentAsync(
|
||||
discoveryResult.Registry,
|
||||
discoveryResult.Repository,
|
||||
referrer,
|
||||
cancellationToken);
|
||||
|
||||
if (referrerContent is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to fetch content for referrer {Digest}",
|
||||
referrer.Digest);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Write referrer to temp file
|
||||
var referrerDir = Path.Combine(
|
||||
tempDir,
|
||||
"referrers",
|
||||
SanitizeDigestForPath(discoveryResult.SubjectDigest));
|
||||
Directory.CreateDirectory(referrerDir);
|
||||
|
||||
var referrerFileName = $"{SanitizeDigestForPath(referrer.Digest)}.json";
|
||||
var referrerFilePath = Path.Combine(referrerDir, referrerFileName);
|
||||
|
||||
await File.WriteAllBytesAsync(referrerFilePath, referrerContent, cancellationToken);
|
||||
|
||||
referrerSources.Add(new MirrorBundleDataSource(
|
||||
category.Value,
|
||||
referrerFilePath,
|
||||
IsNormalized: false,
|
||||
SubjectId: discoveryResult.SubjectDigest));
|
||||
|
||||
_logger.LogDebug(
|
||||
"Collected referrer {Digest} ({Category}) for subject {Subject}",
|
||||
referrer.Digest,
|
||||
category.Value,
|
||||
discoveryResult.SubjectDigest);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error discovering referrers for {ImageRef}", imageRef);
|
||||
// Continue with other images even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return referrerSources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the content of a referrer artifact.
|
||||
/// </summary>
|
||||
private async Task<byte[]?> FetchReferrerContentAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
DiscoveredReferrer referrer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// If the referrer has layers, fetch the first layer content
|
||||
if (referrer.Layers.Count > 0)
|
||||
{
|
||||
var layer = referrer.Layers[0];
|
||||
return await _referrerDiscovery.GetReferrerContentAsync(
|
||||
registry,
|
||||
repository,
|
||||
layer.Digest,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// Otherwise try to fetch by the referrer digest itself
|
||||
return await _referrerDiscovery.GetReferrerContentAsync(
|
||||
registry,
|
||||
repository,
|
||||
referrer.Digest,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a source reference looks like an OCI image reference.
|
||||
/// </summary>
|
||||
private static bool IsImageReference(string? sourceRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceRef))
|
||||
return false;
|
||||
|
||||
// Check if it matches the image reference pattern
|
||||
if (ImageReferencePattern.IsMatch(sourceRef))
|
||||
return true;
|
||||
|
||||
// Check if it contains a digest (sha256:...)
|
||||
if (sourceRef.Contains("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the registry hostname from an image reference.
|
||||
/// </summary>
|
||||
private static string? ExtractRegistry(string? sourceRef)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceRef))
|
||||
return null;
|
||||
|
||||
var match = ImageReferencePattern.Match(sourceRef);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return match.Groups["registry"].Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a digest for use as a filesystem path segment.
|
||||
/// </summary>
|
||||
private static string SanitizeDigestForPath(string digest)
|
||||
{
|
||||
// Replace colon with hyphen: sha256:abc... -> sha256-abc...
|
||||
return digest.Replace(':', '-');
|
||||
}
|
||||
|
||||
private static MirrorBundleDataCategory? MapKindToCategory(string kind)
|
||||
{
|
||||
return kind.ToLowerInvariant() switch
|
||||
@@ -324,6 +576,17 @@ public sealed class MirrorAdapter : IExportAdapter
|
||||
"vex-consensus" => MirrorBundleDataCategory.VexConsensus,
|
||||
"findings" => MirrorBundleDataCategory.Findings,
|
||||
"scan-report" => MirrorBundleDataCategory.Findings,
|
||||
// Attestation types
|
||||
"attestation" => MirrorBundleDataCategory.Attestation,
|
||||
"dsse" => MirrorBundleDataCategory.Attestation,
|
||||
"in-toto" => MirrorBundleDataCategory.Attestation,
|
||||
"intoto" => MirrorBundleDataCategory.Attestation,
|
||||
"provenance" => MirrorBundleDataCategory.Attestation,
|
||||
"signature" => MirrorBundleDataCategory.Attestation,
|
||||
"rva" => MirrorBundleDataCategory.Attestation,
|
||||
// Image types (for referrer discovery)
|
||||
"image" => MirrorBundleDataCategory.Referrer,
|
||||
"container" => MirrorBundleDataCategory.Referrer,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
namespace StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for discovering OCI referrer artifacts linked to images.
|
||||
/// Used by MirrorAdapter to discover SBOMs, attestations, and signatures attached to images.
|
||||
/// </summary>
|
||||
public interface IReferrerDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Probes registry capabilities to determine the best discovery strategy.
|
||||
/// Results are cached per registry host.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Registry capabilities including referrers API support.</returns>
|
||||
Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
|
||||
string registry,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Discovers all referrer artifacts for a given image.
|
||||
/// </summary>
|
||||
/// <param name="imageReference">Full image reference (e.g., registry.example.com/repo:tag or registry.example.com/repo@sha256:...).</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Result containing discovered referrer artifacts.</returns>
|
||||
Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads the content of a referrer artifact.
|
||||
/// </summary>
|
||||
/// <param name="registry">Registry hostname.</param>
|
||||
/// <param name="repository">Repository name.</param>
|
||||
/// <param name="digest">Artifact digest.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Artifact content as bytes, or null if not found.</returns>
|
||||
Task<byte[]?> GetReferrerContentAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registry capabilities information returned from capability probing.
|
||||
/// </summary>
|
||||
public sealed record RegistryCapabilitiesInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI Distribution spec version (e.g., "registry/2.0", "OCI 1.1").
|
||||
/// </summary>
|
||||
public string? DistributionVersion { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports the native OCI 1.1 referrers API.
|
||||
/// </summary>
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports the artifactType field.
|
||||
/// </summary>
|
||||
public bool SupportsArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When capabilities were probed.
|
||||
/// </summary>
|
||||
public DateTimeOffset ProbedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether probing was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Error message if probing failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static RegistryCapabilitiesInfo Failed(string registry, string error) =>
|
||||
new()
|
||||
{
|
||||
Registry = registry,
|
||||
IsSuccess = false,
|
||||
Error = error,
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of referrer discovery for an image.
|
||||
/// </summary>
|
||||
public sealed record ReferrerDiscoveryResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the discovery operation succeeded.
|
||||
/// </summary>
|
||||
public required bool IsSuccess { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject image digest that was queried.
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discovered referrer artifacts.
|
||||
/// </summary>
|
||||
public IReadOnlyList<DiscoveredReferrer> Referrers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Whether the registry supports the native OCI 1.1 referrers API.
|
||||
/// </summary>
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if discovery failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
public static ReferrerDiscoveryResult Failed(string error, string subjectDigest, string registry, string repository) =>
|
||||
new()
|
||||
{
|
||||
IsSuccess = false,
|
||||
SubjectDigest = subjectDigest,
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A discovered referrer artifact.
|
||||
/// </summary>
|
||||
public sealed record DiscoveredReferrer
|
||||
{
|
||||
/// <summary>
|
||||
/// Digest of the referrer manifest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact type (e.g., application/vnd.cyclonedx+json for SBOM).
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the manifest.
|
||||
/// </summary>
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Manifest annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Content layers (for fetching actual artifact data).
|
||||
/// </summary>
|
||||
public IReadOnlyList<ReferrerLayer> Layers { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The category this referrer maps to in a mirror bundle.
|
||||
/// </summary>
|
||||
public MirrorBundleDataCategory? Category => MapArtifactTypeToCategory(ArtifactType);
|
||||
|
||||
private static MirrorBundleDataCategory? MapArtifactTypeToCategory(string? artifactType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(artifactType))
|
||||
return null;
|
||||
|
||||
// SBOM types
|
||||
if (artifactType.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) &&
|
||||
!artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
return MirrorBundleDataCategory.Sbom;
|
||||
|
||||
if (artifactType.Contains("spdx", StringComparison.OrdinalIgnoreCase))
|
||||
return MirrorBundleDataCategory.Sbom;
|
||||
|
||||
// VEX types
|
||||
if (artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("openvex", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("csaf", StringComparison.OrdinalIgnoreCase))
|
||||
return MirrorBundleDataCategory.Vex;
|
||||
|
||||
// Attestation types (DSSE, in-toto, sigstore)
|
||||
if (artifactType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("intoto", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("sigstore", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("provenance", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("slsa", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("rva", StringComparison.OrdinalIgnoreCase))
|
||||
return MirrorBundleDataCategory.Attestation;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A layer within a referrer manifest.
|
||||
/// </summary>
|
||||
public sealed record ReferrerLayer
|
||||
{
|
||||
/// <summary>
|
||||
/// Layer digest.
|
||||
/// </summary>
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer media type.
|
||||
/// </summary>
|
||||
public required string MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer size in bytes.
|
||||
/// </summary>
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Layer annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Annotations { get; init; } = new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Null implementation of IReferrerDiscoveryService for when referrer discovery is disabled.
|
||||
/// </summary>
|
||||
public sealed class NullReferrerDiscoveryService : IReferrerDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton instance.
|
||||
/// </summary>
|
||||
public static readonly NullReferrerDiscoveryService Instance = new();
|
||||
|
||||
private NullReferrerDiscoveryService() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
|
||||
string registry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = registry,
|
||||
SupportsReferrersApi = false,
|
||||
SupportsArtifactType = false,
|
||||
ProbedAt = DateTimeOffset.UtcNow,
|
||||
IsSuccess = true
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = string.Empty,
|
||||
Registry = string.Empty,
|
||||
Repository = string.Empty,
|
||||
Referrers = []
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<byte[]?> GetReferrerContentAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<byte[]?>(null);
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,13 @@ public sealed class MirrorBundleBuilder
|
||||
MirrorBundleDataCategory.PolicyEvaluations => $"data/policy/{fileName}",
|
||||
MirrorBundleDataCategory.VexConsensus => $"data/consensus/{fileName}",
|
||||
MirrorBundleDataCategory.Findings => $"data/findings/{fileName}",
|
||||
// OCI referrer artifacts - stored under referrers/{subject-digest}/
|
||||
MirrorBundleDataCategory.Attestation when !string.IsNullOrEmpty(source.SubjectId) =>
|
||||
$"referrers/{SanitizeSegment(source.SubjectId)}/attestations/{fileName}",
|
||||
MirrorBundleDataCategory.Attestation => $"data/attestations/{fileName}",
|
||||
MirrorBundleDataCategory.Referrer when !string.IsNullOrEmpty(source.SubjectId) =>
|
||||
$"referrers/{SanitizeSegment(source.SubjectId)}/{fileName}",
|
||||
MirrorBundleDataCategory.Referrer => $"data/referrers/{fileName}",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(source), $"Unknown data category: {source.Category}")
|
||||
};
|
||||
}
|
||||
@@ -210,8 +217,10 @@ public sealed class MirrorBundleBuilder
|
||||
var vex = files.Count(f => f.Category is MirrorBundleDataCategory.Vex or MirrorBundleDataCategory.VexConsensus);
|
||||
var sboms = files.Count(f => f.Category == MirrorBundleDataCategory.Sbom);
|
||||
var policyEvals = files.Count(f => f.Category == MirrorBundleDataCategory.PolicyEvaluations);
|
||||
var attestations = files.Count(f => f.Category == MirrorBundleDataCategory.Attestation);
|
||||
var referrers = files.Count(f => f.Category == MirrorBundleDataCategory.Referrer);
|
||||
|
||||
return new MirrorBundleManifestCounts(advisories, vex, sboms, policyEvals);
|
||||
return new MirrorBundleManifestCounts(advisories, vex, sboms, policyEvals, attestations, referrers);
|
||||
}
|
||||
|
||||
private MirrorBundleManifest BuildManifest(
|
||||
@@ -355,6 +364,8 @@ public sealed class MirrorBundleBuilder
|
||||
builder.Append("- VEX statements: ").AppendLine(manifest.Counts.Vex.ToString());
|
||||
builder.Append("- SBOMs: ").AppendLine(manifest.Counts.Sboms.ToString());
|
||||
builder.Append("- Policy evaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
|
||||
builder.Append("- Attestations: ").AppendLine(manifest.Counts.Attestations.ToString());
|
||||
builder.Append("- OCI referrers: ").AppendLine(manifest.Counts.Referrers.ToString());
|
||||
builder.AppendLine();
|
||||
|
||||
if (manifest.Delta is not null)
|
||||
@@ -441,6 +452,8 @@ public sealed class MirrorBundleBuilder
|
||||
builder.Append(" vex: ").AppendLine(manifest.Counts.Vex.ToString());
|
||||
builder.Append(" sboms: ").AppendLine(manifest.Counts.Sboms.ToString());
|
||||
builder.Append(" policyEvaluations: ").AppendLine(manifest.Counts.PolicyEvaluations.ToString());
|
||||
builder.Append(" attestations: ").AppendLine(manifest.Counts.Attestations.ToString());
|
||||
builder.Append(" referrers: ").AppendLine(manifest.Counts.Referrers.ToString());
|
||||
|
||||
builder.AppendLine("artifacts:");
|
||||
foreach (var artifact in manifest.Artifacts)
|
||||
@@ -501,6 +514,8 @@ public sealed class MirrorBundleBuilder
|
||||
WriteTextEntry(tar, "indexes/vex.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/sbom.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/findings.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/attestations.index.json", "[]", DefaultFileMode);
|
||||
WriteTextEntry(tar, "indexes/referrers.index.json", "[]", DefaultFileMode);
|
||||
|
||||
// Write data files
|
||||
foreach (var file in files)
|
||||
|
||||
@@ -60,7 +60,15 @@ public enum MirrorBundleDataCategory
|
||||
PolicySnapshot = 4,
|
||||
PolicyEvaluations = 5,
|
||||
VexConsensus = 6,
|
||||
Findings = 7
|
||||
Findings = 7,
|
||||
/// <summary>
|
||||
/// Attestations discovered via OCI referrers (DSSE, in-toto, provenance, signatures).
|
||||
/// </summary>
|
||||
Attestation = 8,
|
||||
/// <summary>
|
||||
/// OCI referrer artifacts that don't fit other categories.
|
||||
/// </summary>
|
||||
Referrer = 9
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -137,7 +145,9 @@ public sealed record MirrorBundleManifestCounts(
|
||||
[property: JsonPropertyName("advisories")] int Advisories,
|
||||
[property: JsonPropertyName("vex")] int Vex,
|
||||
[property: JsonPropertyName("sboms")] int Sboms,
|
||||
[property: JsonPropertyName("policyEvaluations")] int PolicyEvaluations);
|
||||
[property: JsonPropertyName("policyEvaluations")] int PolicyEvaluations,
|
||||
[property: JsonPropertyName("attestations")] int Attestations = 0,
|
||||
[property: JsonPropertyName("referrers")] int Referrers = 0);
|
||||
|
||||
/// <summary>
|
||||
/// Artifact entry in the manifest.
|
||||
@@ -244,3 +254,217 @@ public sealed record MirrorBundleDsseSignature(
|
||||
public sealed record MirrorBundleDsseSignatureEntry(
|
||||
[property: JsonPropertyName("sig")] string Signature,
|
||||
[property: JsonPropertyName("keyid")] string KeyId);
|
||||
|
||||
// ============================================================================
|
||||
// OCI Referrer Discovery Models
|
||||
// ============================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Referrer metadata section in the mirror bundle manifest.
|
||||
/// Tracks OCI referrer artifacts (SBOMs, attestations, signatures) discovered for images.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleReferrersSection
|
||||
{
|
||||
/// <summary>
|
||||
/// List of subject images and their discovered referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjects")]
|
||||
public IReadOnlyList<MirrorBundleSubjectReferrers> Subjects { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts of referrer artifacts.
|
||||
/// </summary>
|
||||
[JsonPropertyName("counts")]
|
||||
public MirrorBundleReferrerCounts Counts { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Whether the source registry supports native OCI 1.1 referrers API.
|
||||
/// </summary>
|
||||
[JsonPropertyName("supportsReferrersApi")]
|
||||
public bool SupportsReferrersApi { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Discovery method used (native or fallback).
|
||||
/// </summary>
|
||||
[JsonPropertyName("discoveryMethod")]
|
||||
public string DiscoveryMethod { get; init; } = "native";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Referrers for a specific subject image.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleSubjectReferrers
|
||||
{
|
||||
/// <summary>
|
||||
/// Subject image digest (sha256:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("subject")]
|
||||
public required string Subject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject image reference (if available).
|
||||
/// </summary>
|
||||
[JsonPropertyName("reference")]
|
||||
public string? Reference { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry hostname.
|
||||
/// </summary>
|
||||
[JsonPropertyName("registry")]
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("repository")]
|
||||
public required string Repository { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Referrer artifacts attached to this subject.
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifacts")]
|
||||
public IReadOnlyList<MirrorBundleReferrerArtifact> Artifacts { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A referrer artifact in the mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleReferrerArtifact
|
||||
{
|
||||
/// <summary>
|
||||
/// Artifact digest (sha256:...).
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public required string Digest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI artifact type (e.g., application/vnd.cyclonedx+json).
|
||||
/// </summary>
|
||||
[JsonPropertyName("artifactType")]
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Media type of the artifact manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mediaType")]
|
||||
public string? MediaType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Size in bytes.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public long Size { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Category in the bundle (sbom, attestation, vex, etc.).
|
||||
/// </summary>
|
||||
[JsonPropertyName("category")]
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative path within the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("path")]
|
||||
public required string Path { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 hash of the artifact content in the bundle.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sha256")]
|
||||
public required string Sha256 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact annotations from the OCI manifest.
|
||||
/// </summary>
|
||||
[JsonPropertyName("annotations")]
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp when the artifact was created (from annotations).
|
||||
/// </summary>
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTimeOffset? CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary counts of referrer artifacts in the bundle.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleReferrerCounts
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of subject images with referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("subjects")]
|
||||
public int Subjects { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total referrer artifacts across all subjects.
|
||||
/// </summary>
|
||||
[JsonPropertyName("total")]
|
||||
public int Total { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of SBOM referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sboms")]
|
||||
public int Sboms { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of attestation referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attestations")]
|
||||
public int Attestations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of VEX referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("vex")]
|
||||
public int Vex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of other/unknown referrers.
|
||||
/// </summary>
|
||||
[JsonPropertyName("other")]
|
||||
public int Other { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended data source that includes referrer metadata.
|
||||
/// </summary>
|
||||
public sealed record MirrorBundleReferrerDataSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Base data source information.
|
||||
/// </summary>
|
||||
public required MirrorBundleDataSource DataSource { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Subject image digest this referrer is attached to.
|
||||
/// </summary>
|
||||
public required string SubjectDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Referrer artifact digest.
|
||||
/// </summary>
|
||||
public required string ReferrerDigest { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// OCI artifact type.
|
||||
/// </summary>
|
||||
public string? ArtifactType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Artifact annotations.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Annotations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Registry hostname.
|
||||
/// </summary>
|
||||
public required string Registry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Repository name.
|
||||
/// </summary>
|
||||
public required string Repository { get; init; }
|
||||
}
|
||||
|
||||
@@ -28,11 +28,24 @@ public sealed record OfflineKitMirrorEntry(
|
||||
[property: JsonPropertyName("rootHash")] string RootHash,
|
||||
[property: JsonPropertyName("artifact")] string Artifact,
|
||||
[property: JsonPropertyName("checksum")] string Checksum,
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt)
|
||||
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||
[property: JsonPropertyName("referrers")] OfflineKitReferrersSummary? Referrers = null)
|
||||
{
|
||||
public const string KindValue = "mirror-bundle";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of OCI referrer artifacts included in a mirror bundle.
|
||||
/// </summary>
|
||||
public sealed record OfflineKitReferrersSummary(
|
||||
[property: JsonPropertyName("totalSubjects")] int TotalSubjects,
|
||||
[property: JsonPropertyName("totalArtifacts")] int TotalArtifacts,
|
||||
[property: JsonPropertyName("sbomCount")] int SbomCount,
|
||||
[property: JsonPropertyName("attestationCount")] int AttestationCount,
|
||||
[property: JsonPropertyName("vexCount")] int VexCount,
|
||||
[property: JsonPropertyName("otherCount")] int OtherCount,
|
||||
[property: JsonPropertyName("supportsReferrersApi")] bool SupportsReferrersApi);
|
||||
|
||||
/// <summary>
|
||||
/// Manifest entry for a bootstrap pack in an offline kit.
|
||||
/// </summary>
|
||||
@@ -122,7 +135,8 @@ public sealed record OfflineKitMirrorRequest(
|
||||
string Profile,
|
||||
string RootHash,
|
||||
byte[] BundleBytes,
|
||||
DateTimeOffset CreatedAt);
|
||||
DateTimeOffset CreatedAt,
|
||||
OfflineKitReferrersSummary? Referrers = null);
|
||||
|
||||
/// <summary>
|
||||
/// Request to add a bootstrap pack to an offline kit.
|
||||
|
||||
@@ -245,7 +245,8 @@ public sealed class OfflineKitPackager
|
||||
RootHash: $"sha256:{request.RootHash}",
|
||||
Artifact: Path.Combine(MirrorsDir, MirrorBundleFileName).Replace('\\', '/'),
|
||||
Checksum: Path.Combine(ChecksumsDir, MirrorsDir, $"{MirrorBundleFileName}.sha256").Replace('\\', '/'),
|
||||
CreatedAt: request.CreatedAt);
|
||||
CreatedAt: request.CreatedAt,
|
||||
Referrers: request.Referrers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,851 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.Cryptography;
|
||||
using StellaOps.Determinism;
|
||||
using StellaOps.ExportCenter.Core.Adapters;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
using StellaOps.ExportCenter.Core.Planner;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for MirrorAdapter OCI referrer discovery integration.
|
||||
/// </summary>
|
||||
public sealed class MirrorAdapterReferrerDiscoveryTests : IDisposable
|
||||
{
|
||||
private readonly ICryptoHash _cryptoHash;
|
||||
private readonly Mock<IReferrerDiscoveryService> _mockReferrerDiscovery;
|
||||
private readonly MirrorAdapter _adapter;
|
||||
private readonly string _tempDir;
|
||||
private static readonly DateTimeOffset FixedTime = new(2025, 1, 27, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
public MirrorAdapterReferrerDiscoveryTests()
|
||||
{
|
||||
_cryptoHash = new FakeCryptoHash();
|
||||
_mockReferrerDiscovery = new Mock<IReferrerDiscoveryService>();
|
||||
_adapter = new MirrorAdapter(
|
||||
NullLogger<MirrorAdapter>.Instance,
|
||||
_cryptoHash,
|
||||
_mockReferrerDiscovery.Object);
|
||||
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"mirror-referrer-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
try { Directory.Delete(_tempDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AdapterId_IsMirrorStandard()
|
||||
{
|
||||
Assert.Equal("mirror:standard", _adapter.AdapterId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_WithNullReferrerDiscovery_UsesNullImplementation()
|
||||
{
|
||||
// When no referrer discovery service is provided, adapter should use NullReferrerDiscoveryService
|
||||
var adapter = new MirrorAdapter(
|
||||
NullLogger<MirrorAdapter>.Instance,
|
||||
_cryptoHash,
|
||||
referrerDiscovery: null);
|
||||
|
||||
Assert.Equal("mirror:standard", adapter.AdapterId);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_WithImageReference_DiscoverReferrers()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456";
|
||||
var sbomContent = "{\"bomFormat\":\"CycloneDX\",\"specVersion\":\"1.5\"}"u8.ToArray();
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123def456",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:sbom111",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
MediaType = "application/vnd.oci.image.manifest.v1+json",
|
||||
Size = sbomContent.Length,
|
||||
Layers =
|
||||
[
|
||||
new ReferrerLayer
|
||||
{
|
||||
Digest = "sha256:sbom-layer111",
|
||||
MediaType = "application/vnd.cyclonedx+json",
|
||||
Size = sbomContent.Length
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:sbom-layer111",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sbomContent);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
Kind = "sbom",
|
||||
Name = "myapp-sbom",
|
||||
SourceRef = imageRef,
|
||||
CreatedAt = FixedTime
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_WithoutImageReference_SkipsReferrerDiscovery()
|
||||
{
|
||||
// Arrange - a regular VEX file without image reference
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
Kind = "vex",
|
||||
Name = "vex-document",
|
||||
SourceRef = "local://vex-document.json",
|
||||
CreatedAt = FixedTime
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ReferrerDiscoveryFails_ContinuesWithoutError()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(ReferrerDiscoveryResult.Failed(
|
||||
"Registry unavailable",
|
||||
"sha256:abc123",
|
||||
"registry.example.com",
|
||||
"myapp"));
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
Kind = "sbom",
|
||||
Name = "myapp-sbom",
|
||||
SourceRef = imageRef,
|
||||
CreatedAt = FixedTime
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert - should succeed even when referrer discovery fails
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_NoReferrersFound_ContinuesSuccessfully()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
Kind = "sbom",
|
||||
Name = "myapp-sbom",
|
||||
SourceRef = imageRef,
|
||||
CreatedAt = FixedTime
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_MultipleImagesWithReferrers_CollectsAll()
|
||||
{
|
||||
// Arrange
|
||||
var image1 = "registry.example.com/app1@sha256:111";
|
||||
var image2 = "registry.example.com/app2@sha256:222";
|
||||
var sbomContent1 = "{\"app\":\"app1\"}"u8.ToArray();
|
||||
var sbomContent2 = "{\"app\":\"app2\"}"u8.ToArray();
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(image1, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:111",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "app1",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:sbom1",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:layer1", MediaType = "application/json" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(image2, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:222",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "app2",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:sbom2",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:layer2", MediaType = "application/json" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "app1", "sha256:layer1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sbomContent1);
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "app2", "sha256:layer2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sbomContent2);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app1", SourceRef = image1, CreatedAt = FixedTime },
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app2", SourceRef = image2, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_DuplicateReferrers_Deduplicated()
|
||||
{
|
||||
// Arrange - same referrer for same image (e.g., discovered twice)
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
var sbomContent = "{\"dedupe\":\"test\"}"u8.ToArray();
|
||||
var sameReferrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:same-sbom",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:layer-same", MediaType = "application/json" }]
|
||||
};
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = [sameReferrer, sameReferrer] // Duplicate
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:layer-same", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(sbomContent);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
// Should only fetch content once due to deduplication
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:layer-same", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_AttestationReferrer_CategorizedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
var dsseContent = "{\"payloadType\":\"application/vnd.in-toto+json\"}"u8.ToArray();
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:attestation1",
|
||||
ArtifactType = "application/vnd.dsse.envelope.v1+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:dsse-layer", MediaType = "application/vnd.dsse.envelope.v1+json" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:dsse-layer", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(dsseContent);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_VexReferrer_CategorizedCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
var vexContent = "{\"document\":{\"category\":\"informational_advisory\"}}"u8.ToArray();
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:vex1",
|
||||
ArtifactType = "application/vnd.openvex+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:vex-layer", MediaType = "application/vnd.openvex+json" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:vex-layer", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(vexContent);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ReferrerContentFetchFails_ContinuesWithOthers()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123";
|
||||
var goodContent = "{\"success\":true}"u8.ToArray();
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers =
|
||||
[
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:fail",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:fail-layer", MediaType = "application/json" }]
|
||||
},
|
||||
new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:succeed",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers = [new ReferrerLayer { Digest = "sha256:good-layer", MediaType = "application/json" }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:fail-layer", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((byte[]?)null);
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.GetReferrerContentAsync("registry.example.com", "myapp", "sha256:good-layer", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(goodContent);
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "myapp", SourceRef = imageRef, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsSbomCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/vnd.cyclonedx+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Sbom, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsSpdxCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/spdx+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Sbom, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsVexCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/vnd.openvex+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Vex, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsCsafVexCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/csaf+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Vex, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsDsseCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/vnd.dsse.envelope.v1+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsInTotoCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/vnd.in-toto+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_MapsSlsaCorrectly()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/vnd.slsa.provenance+json"
|
||||
};
|
||||
|
||||
Assert.Equal(MirrorBundleDataCategory.Attestation, referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void DiscoveredReferrer_Category_ReturnsNullForUnknown()
|
||||
{
|
||||
var referrer = new DiscoveredReferrer
|
||||
{
|
||||
Digest = "sha256:test",
|
||||
ArtifactType = "application/unknown"
|
||||
};
|
||||
|
||||
Assert.Null(referrer.Category);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ProbesRegistryCapabilities_BeforeDiscovery()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456";
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.ProbeRegistryCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
SupportsReferrersApi = true,
|
||||
SupportsArtifactType = true,
|
||||
DistributionVersion = "OCI 1.1",
|
||||
ProbedAt = FixedTime,
|
||||
IsSuccess = true
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = "sha256:abc123def456",
|
||||
Registry = "registry.example.com",
|
||||
Repository = "myapp",
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem
|
||||
{
|
||||
ItemId = Guid.NewGuid(),
|
||||
Kind = "sbom",
|
||||
Name = "myapp-sbom",
|
||||
SourceRef = imageRef,
|
||||
CreatedAt = FixedTime
|
||||
}
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
// Verify ProbeRegistryCapabilitiesAsync was called before DiscoverReferrersAsync
|
||||
var probeCallOrder = new List<string>();
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.ProbeRegistryCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.DiscoverReferrersAsync(imageRef, It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProcessAsync_ProbesMultipleRegistries_OnceEach()
|
||||
{
|
||||
// Arrange
|
||||
var image1 = "registry1.example.com/app1@sha256:111";
|
||||
var image2 = "registry2.example.com/app2@sha256:222";
|
||||
var image3 = "registry1.example.com/app3@sha256:333"; // Same registry as image1
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.ProbeRegistryCapabilitiesAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string reg, CancellationToken _) => new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = reg,
|
||||
SupportsReferrersApi = reg.Contains("registry1"),
|
||||
ProbedAt = FixedTime,
|
||||
IsSuccess = true
|
||||
});
|
||||
|
||||
_mockReferrerDiscovery
|
||||
.Setup(x => x.DiscoverReferrersAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((string imageRef, CancellationToken _) => new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = imageRef.Contains("111") ? "sha256:111" : imageRef.Contains("222") ? "sha256:222" : "sha256:333",
|
||||
Registry = imageRef.Contains("registry1") ? "registry1.example.com" : "registry2.example.com",
|
||||
Repository = imageRef.Contains("app1") ? "app1" : imageRef.Contains("app2") ? "app2" : "app3",
|
||||
SupportsReferrersApi = imageRef.Contains("registry1"),
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
var context = CreateContext(
|
||||
items:
|
||||
[
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app1", SourceRef = image1, CreatedAt = FixedTime },
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app2", SourceRef = image2, CreatedAt = FixedTime },
|
||||
new ResolvedExportItem { ItemId = Guid.NewGuid(), Kind = "sbom", Name = "app3", SourceRef = image3, CreatedAt = FixedTime }
|
||||
]);
|
||||
|
||||
// Act
|
||||
var result = await _adapter.ProcessAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.Success);
|
||||
// Each unique registry should be probed exactly once
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.ProbeRegistryCapabilitiesAsync("registry1.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
_mockReferrerDiscovery.Verify(
|
||||
x => x.ProbeRegistryCapabilitiesAsync("registry2.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NullReferrerDiscoveryService_ProbeRegistryCapabilitiesAsync_ReturnsDefaultCapabilities()
|
||||
{
|
||||
var result = NullReferrerDiscoveryService.Instance.ProbeRegistryCapabilitiesAsync("test.registry.io", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("test.registry.io", result.Registry);
|
||||
Assert.False(result.SupportsReferrersApi);
|
||||
Assert.False(result.SupportsArtifactType);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NullReferrerDiscoveryService_DiscoverReferrersAsync_ReturnsEmptyResult()
|
||||
{
|
||||
var result = NullReferrerDiscoveryService.Instance.DiscoverReferrersAsync("test", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Empty(result.Referrers);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void NullReferrerDiscoveryService_GetReferrerContentAsync_ReturnsNull()
|
||||
{
|
||||
var result = NullReferrerDiscoveryService.Instance.GetReferrerContentAsync("reg", "repo", "digest", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
private ExportAdapterContext CreateContext(IReadOnlyList<ResolvedExportItem> items)
|
||||
{
|
||||
var outputDir = Path.Combine(_tempDir, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var dataFetcher = new InMemoryExportDataFetcher();
|
||||
foreach (var item in items)
|
||||
{
|
||||
dataFetcher.AddContent(item.ItemId, $"{{\"id\":\"{item.ItemId}\"}}");
|
||||
}
|
||||
|
||||
return new ExportAdapterContext
|
||||
{
|
||||
Items = items,
|
||||
Config = new ExportAdapterConfig
|
||||
{
|
||||
AdapterId = "mirror:standard",
|
||||
OutputDirectory = outputDir,
|
||||
BaseName = "test-export",
|
||||
FormatOptions = new ExportFormatOptions
|
||||
{
|
||||
Format = ExportFormat.Mirror,
|
||||
SortKeys = false,
|
||||
NormalizeTimestamps = false
|
||||
},
|
||||
IncludeChecksums = false
|
||||
},
|
||||
DataFetcher = dataFetcher,
|
||||
CorrelationId = Guid.NewGuid().ToString(),
|
||||
TenantId = Guid.NewGuid(),
|
||||
TimeProvider = new FakeTimeProvider(FixedTime),
|
||||
GuidProvider = new SequentialGuidProvider()
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private readonly DateTimeOffset _utcNow;
|
||||
public FakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow;
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
}
|
||||
|
||||
private sealed class SequentialGuidProvider : IGuidProvider
|
||||
{
|
||||
private int _counter;
|
||||
public Guid NewGuid() => new Guid(_counter++, 0, 0, [0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
private sealed class FakeCryptoHash : ICryptoHash
|
||||
{
|
||||
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
return sha256.ComputeHash(data.ToArray());
|
||||
}
|
||||
|
||||
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
var hash = ComputeHash(data, algorithmId);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
|
||||
{
|
||||
var hash = ComputeHash(data, algorithmId);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha256.ComputeHash(stream);
|
||||
return new ValueTask<byte[]>(hash);
|
||||
}
|
||||
|
||||
public async ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var hash = await ComputeHashAsync(stream, algorithmId, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHash(data, null);
|
||||
|
||||
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashHex(data, null);
|
||||
|
||||
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> ComputeHashBase64(data, null);
|
||||
|
||||
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashAsync(stream, null, cancellationToken);
|
||||
|
||||
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
|
||||
=> ComputeHashHexAsync(stream, null, cancellationToken);
|
||||
|
||||
public string GetAlgorithmForPurpose(string purpose) => "sha256";
|
||||
|
||||
public string GetHashPrefix(string purpose) => "sha256:";
|
||||
|
||||
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
|
||||
=> GetHashPrefix(purpose) + ComputeHashHexForPurpose(data, purpose);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
using StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ExportCenter.Tests.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for OciReferrerDiscoveryService which wraps IOciReferrerDiscovery for use in MirrorAdapter.
|
||||
/// </summary>
|
||||
public sealed class OciReferrerDiscoveryServiceTests
|
||||
{
|
||||
private readonly Mock<IOciReferrerDiscovery> _mockDiscovery;
|
||||
private readonly OciReferrerDiscoveryService _service;
|
||||
|
||||
public OciReferrerDiscoveryServiceTests()
|
||||
{
|
||||
_mockDiscovery = new Mock<IOciReferrerDiscovery>();
|
||||
_service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_ValidDigestReference_ReturnsResults()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
var referrerInfo = new ReferrerInfo
|
||||
{
|
||||
Digest = "sha256:referrer111",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
MediaType = "application/vnd.oci.image.manifest.v1+json",
|
||||
Size = 1234
|
||||
};
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = [referrerInfo]
|
||||
});
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.GetReferrerManifestAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:referrer111",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerManifest
|
||||
{
|
||||
Digest = "sha256:referrer111",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
Layers =
|
||||
[
|
||||
new StellaOps.ExportCenter.WebService.Distribution.Oci.ReferrerLayer
|
||||
{
|
||||
Digest = "sha256:layer1",
|
||||
MediaType = "application/vnd.cyclonedx+json",
|
||||
Size = 1234
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("myapp");
|
||||
result.SubjectDigest.Should().Be("sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd");
|
||||
result.SupportsReferrersApi.Should().BeTrue();
|
||||
result.Referrers.Should().HaveCount(1);
|
||||
result.Referrers[0].Digest.Should().Be("sha256:referrer111");
|
||||
result.Referrers[0].ArtifactType.Should().Be("application/vnd.cyclonedx+json");
|
||||
result.Referrers[0].Layers.Should().HaveCount(1);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_TagReference_ReturnsFailure()
|
||||
{
|
||||
// Arrange - tag references cannot be used directly for referrer discovery
|
||||
var imageRef = "registry.example.com/myapp:v1.0.0";
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid image reference");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_InvalidReference_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "not-a-valid-reference";
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Invalid image reference");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_EmptyReference_ReturnsFailure()
|
||||
{
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync("");
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_RegistryError_ReturnsFailure()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
Error = "Registry connection failed"
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeFalse();
|
||||
result.Error.Should().Contain("Registry connection failed");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_NoReferrers_ReturnsEmptyList()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Referrers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_WithPort_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "localhost:5000/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
"localhost:5000",
|
||||
"myapp",
|
||||
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Registry.Should().Be("localhost:5000");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_NestedRepository_ParsesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/org/project/app@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
"registry.example.com",
|
||||
"org/project/app",
|
||||
"sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd",
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Registry.Should().Be("registry.example.com");
|
||||
result.Repository.Should().Be("org/project/app");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_FallbackToTags_ReportsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = false, // Using fallback
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.SupportsReferrersApi.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetReferrerContentAsync_ValidDigest_ReturnsContent()
|
||||
{
|
||||
// Arrange
|
||||
var content = "{\"test\":\"content\"}"u8.ToArray();
|
||||
_mockDiscovery
|
||||
.Setup(x => x.GetLayerContentAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:layer123",
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(content);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetReferrerContentAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:layer123");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Should().BeEquivalentTo(content);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetReferrerContentAsync_NotFound_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
_mockDiscovery
|
||||
.Setup(x => x.GetLayerContentAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new HttpRequestException("Not found"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetReferrerContentAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
"sha256:nonexistent");
|
||||
|
||||
// Assert
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_ManifestFetchFails_IncludesReferrerWithEmptyLayers()
|
||||
{
|
||||
// Arrange
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
var referrerInfo = new ReferrerInfo
|
||||
{
|
||||
Digest = "sha256:referrer111",
|
||||
ArtifactType = "application/vnd.cyclonedx+json",
|
||||
MediaType = "application/vnd.oci.image.manifest.v1+json",
|
||||
Size = 1234
|
||||
};
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = [referrerInfo]
|
||||
});
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.GetReferrerManifestAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((ReferrerManifest?)null);
|
||||
|
||||
// Act
|
||||
var result = await _service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.Referrers.Should().HaveCount(1);
|
||||
result.Referrers[0].Layers.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void AddOciReferrerDiscoveryService_RegistersService()
|
||||
{
|
||||
// Arrange
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IOciReferrerDiscovery>(_ => _mockDiscovery.Object);
|
||||
services.AddLogging();
|
||||
|
||||
// Act
|
||||
services.AddOciReferrerDiscoveryService();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
// Assert
|
||||
var service = provider.GetService<IReferrerDiscoveryService>();
|
||||
service.Should().NotBeNull();
|
||||
service.Should().BeOfType<OciReferrerDiscoveryService>();
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullDiscovery_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new OciReferrerDiscoveryService(null!, NullLogger<OciReferrerDiscoveryService>.Instance));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_ThrowsArgumentNull()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() =>
|
||||
new OciReferrerDiscoveryService(_mockDiscovery.Object, null!));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProbeRegistryCapabilitiesAsync_WithFallback_ReturnsCapabilities()
|
||||
{
|
||||
// Arrange
|
||||
var mockFallback = new Mock<IOciReferrerFallback>();
|
||||
var capabilities = new RegistryCapabilities
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
SupportsReferrersApi = true,
|
||||
DistributionVersion = "1.1.0",
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockFallback
|
||||
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(capabilities);
|
||||
|
||||
var service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance,
|
||||
mockFallback.Object);
|
||||
|
||||
// Act
|
||||
var result = await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.SupportsReferrersApi.Should().BeTrue();
|
||||
result.DistributionVersion.Should().Be("1.1.0");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProbeRegistryCapabilitiesAsync_WithoutFallback_ReturnsDefaultCapabilities()
|
||||
{
|
||||
// Arrange - service without fallback
|
||||
var service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance);
|
||||
|
||||
// Act
|
||||
var result = await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsSuccess.Should().BeTrue();
|
||||
result.SupportsReferrersApi.Should().BeFalse();
|
||||
result.Registry.Should().Be("registry.example.com");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ProbeRegistryCapabilitiesAsync_CachesResult()
|
||||
{
|
||||
// Arrange
|
||||
var mockFallback = new Mock<IOciReferrerFallback>();
|
||||
var capabilities = new RegistryCapabilities
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
SupportsReferrersApi = true,
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockFallback
|
||||
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(capabilities);
|
||||
|
||||
var service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance,
|
||||
mockFallback.Object);
|
||||
|
||||
// Act - call twice
|
||||
await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
|
||||
await service.ProbeRegistryCapabilitiesAsync("registry.example.com");
|
||||
|
||||
// Assert - should only call fallback once
|
||||
mockFallback.Verify(
|
||||
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task DiscoverReferrersAsync_ProbesCapabilitiesBeforeDiscovery()
|
||||
{
|
||||
// Arrange
|
||||
var mockFallback = new Mock<IOciReferrerFallback>();
|
||||
var capabilities = new RegistryCapabilities
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
SupportsReferrersApi = true,
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockFallback
|
||||
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(capabilities);
|
||||
|
||||
_mockDiscovery
|
||||
.Setup(x => x.ListReferrersAsync(
|
||||
"registry.example.com",
|
||||
"myapp",
|
||||
It.IsAny<string>(),
|
||||
null,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new ReferrerListResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SupportsReferrersApi = true,
|
||||
Referrers = []
|
||||
});
|
||||
|
||||
var service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance,
|
||||
mockFallback.Object);
|
||||
|
||||
var imageRef = "registry.example.com/myapp@sha256:abc123def456789abc123def456789abc123def456789abc123def456789abcd";
|
||||
|
||||
// Act
|
||||
await service.DiscoverReferrersAsync(imageRef);
|
||||
|
||||
// Assert - capabilities should be probed
|
||||
mockFallback.Verify(
|
||||
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ClearProbedRegistriesCache_ClearsCachedCapabilities()
|
||||
{
|
||||
// Arrange
|
||||
var mockFallback = new Mock<IOciReferrerFallback>();
|
||||
var capabilities = new RegistryCapabilities
|
||||
{
|
||||
Registry = "registry.example.com",
|
||||
SupportsReferrersApi = true,
|
||||
ProbedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
mockFallback
|
||||
.Setup(x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(capabilities);
|
||||
|
||||
var service = new OciReferrerDiscoveryService(
|
||||
_mockDiscovery.Object,
|
||||
NullLogger<OciReferrerDiscoveryService>.Instance,
|
||||
mockFallback.Object);
|
||||
|
||||
// Act - probe, clear cache, probe again
|
||||
service.ProbeRegistryCapabilitiesAsync("registry.example.com").Wait();
|
||||
service.ClearProbedRegistriesCache();
|
||||
service.ProbeRegistryCapabilitiesAsync("registry.example.com").Wait();
|
||||
|
||||
// Assert - should call fallback twice after clearing cache
|
||||
mockFallback.Verify(
|
||||
x => x.ProbeCapabilitiesAsync("registry.example.com", It.IsAny<CancellationToken>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.ExportCenter.Core.MirrorBundle;
|
||||
using StellaOps.ExportCenter.WebService.Telemetry;
|
||||
|
||||
namespace StellaOps.ExportCenter.WebService.Distribution.Oci;
|
||||
|
||||
/// <summary>
|
||||
/// Implementation of IReferrerDiscoveryService that wraps OciReferrerDiscovery.
|
||||
/// Provides OCI referrer discovery for mirror bundle exports with capability probing,
|
||||
/// logging, and metrics.
|
||||
/// </summary>
|
||||
public sealed class OciReferrerDiscoveryService : IReferrerDiscoveryService
|
||||
{
|
||||
// Regex to parse image references: registry/repo:tag or registry/repo@sha256:...
|
||||
private static readonly Regex ImageReferencePattern = new(
|
||||
@"^(?<registry>[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z0-9](:[0-9]+)?)/(?<repository>[a-zA-Z0-9][-a-zA-Z0-9._/]*)(?<separator>[:@])(?<reference>.+)$",
|
||||
RegexOptions.Compiled | RegexOptions.ExplicitCapture);
|
||||
|
||||
private readonly IOciReferrerDiscovery _discovery;
|
||||
private readonly IOciReferrerFallback? _fallback;
|
||||
private readonly ILogger<OciReferrerDiscoveryService> _logger;
|
||||
|
||||
// Track probed registries to log once per export session
|
||||
private readonly ConcurrentDictionary<string, RegistryCapabilities> _probedRegistries = new();
|
||||
|
||||
public OciReferrerDiscoveryService(
|
||||
IOciReferrerDiscovery discovery,
|
||||
ILogger<OciReferrerDiscoveryService> logger,
|
||||
IOciReferrerFallback? fallback = null)
|
||||
{
|
||||
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_fallback = fallback;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<RegistryCapabilitiesInfo> ProbeRegistryCapabilitiesAsync(
|
||||
string registry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_fallback is null)
|
||||
{
|
||||
_logger.LogDebug("Registry capability probing not available (no fallback service)");
|
||||
return new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = registry,
|
||||
SupportsReferrersApi = false,
|
||||
SupportsArtifactType = false,
|
||||
ProbedAt = DateTimeOffset.UtcNow,
|
||||
IsSuccess = true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already probed in this session
|
||||
if (_probedRegistries.TryGetValue(registry, out var cached))
|
||||
{
|
||||
return new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = registry,
|
||||
DistributionVersion = cached.DistributionVersion,
|
||||
SupportsReferrersApi = cached.SupportsReferrersApi,
|
||||
SupportsArtifactType = cached.SupportsArtifactType,
|
||||
ProbedAt = cached.ProbedAt,
|
||||
IsSuccess = true
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var capabilities = await _fallback.ProbeCapabilitiesAsync(registry, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
// Cache for this session
|
||||
_probedRegistries.TryAdd(registry, capabilities);
|
||||
|
||||
// Log capabilities
|
||||
if (capabilities.SupportsReferrersApi)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Registry {Registry}: OCI 1.1 (referrers API supported, version={Version}, probe_ms={ProbeMs})",
|
||||
registry,
|
||||
capabilities.DistributionVersion ?? "unknown",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Registry {Registry}: OCI 1.0 (using fallback tag discovery, version={Version}, probe_ms={ProbeMs})",
|
||||
registry,
|
||||
capabilities.DistributionVersion ?? "unknown",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
ExportTelemetry.RegistryCapabilitiesProbedTotal.Add(1,
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.ApiSupported, capabilities.SupportsReferrersApi.ToString().ToLowerInvariant()));
|
||||
|
||||
return new RegistryCapabilitiesInfo
|
||||
{
|
||||
Registry = registry,
|
||||
DistributionVersion = capabilities.DistributionVersion,
|
||||
SupportsReferrersApi = capabilities.SupportsReferrersApi,
|
||||
SupportsArtifactType = capabilities.SupportsArtifactType,
|
||||
ProbedAt = capabilities.ProbedAt,
|
||||
IsSuccess = true
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to probe capabilities for registry {Registry}", registry);
|
||||
return RegistryCapabilitiesInfo.Failed(registry, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReferrerDiscoveryResult> DiscoverReferrersAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var parsed = ParseImageReference(imageReference);
|
||||
if (parsed is null)
|
||||
{
|
||||
return ReferrerDiscoveryResult.Failed(
|
||||
$"Invalid image reference format: {imageReference}",
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty);
|
||||
}
|
||||
|
||||
var (registry, repository, digest) = parsed.Value;
|
||||
|
||||
_logger.LogDebug(
|
||||
"Discovering referrers for {Registry}/{Repository}@{Digest}",
|
||||
registry, repository, digest);
|
||||
|
||||
// Probe capabilities first (if not already done for this registry)
|
||||
await ProbeRegistryCapabilitiesAsync(registry, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// List all referrers (no filter - get everything)
|
||||
var result = await _discovery.ListReferrersAsync(
|
||||
registry, repository, digest, filter: null, cancellationToken);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
// Record failure metric
|
||||
ExportTelemetry.ReferrerDiscoveryFailuresTotal.Add(1,
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.ErrorType, "discovery_failed"));
|
||||
|
||||
return ReferrerDiscoveryResult.Failed(
|
||||
result.Error ?? "Unknown error during referrer discovery",
|
||||
digest,
|
||||
registry,
|
||||
repository);
|
||||
}
|
||||
|
||||
// Record discovery method metric
|
||||
var discoveryMethod = result.SupportsReferrersApi
|
||||
? ReferrerDiscoveryMethods.Native
|
||||
: ReferrerDiscoveryMethods.Fallback;
|
||||
ExportTelemetry.ReferrerDiscoveryMethodTotal.Add(1,
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.DiscoveryMethod, discoveryMethod));
|
||||
|
||||
// Convert to DiscoveredReferrer records with full manifest info
|
||||
var referrers = new List<DiscoveredReferrer>();
|
||||
|
||||
foreach (var referrerInfo in result.Referrers)
|
||||
{
|
||||
// Get full manifest to retrieve layers
|
||||
var manifest = await _discovery.GetReferrerManifestAsync(
|
||||
registry, repository, referrerInfo.Digest, cancellationToken);
|
||||
|
||||
var layers = manifest?.Layers
|
||||
.Select(l => new Core.MirrorBundle.ReferrerLayer
|
||||
{
|
||||
Digest = l.Digest,
|
||||
MediaType = l.MediaType,
|
||||
Size = l.Size,
|
||||
Annotations = l.Annotations
|
||||
})
|
||||
.ToList() ?? [];
|
||||
|
||||
referrers.Add(new DiscoveredReferrer
|
||||
{
|
||||
Digest = referrerInfo.Digest,
|
||||
ArtifactType = referrerInfo.ArtifactType,
|
||||
MediaType = referrerInfo.MediaType,
|
||||
Size = referrerInfo.Size,
|
||||
Annotations = referrerInfo.Annotations,
|
||||
Layers = layers
|
||||
});
|
||||
|
||||
// Record referrer discovered metric
|
||||
var artifactTypeTag = GetArtifactTypeTag(referrerInfo.ArtifactType);
|
||||
ExportTelemetry.ReferrersDiscoveredTotal.Add(1,
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.ArtifactType, artifactTypeTag));
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Discovered {Count} referrers for {Registry}/{Repository}@{Digest} (method={Method})",
|
||||
referrers.Count,
|
||||
registry,
|
||||
repository,
|
||||
digest,
|
||||
discoveryMethod);
|
||||
|
||||
return new ReferrerDiscoveryResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
SubjectDigest = digest,
|
||||
Registry = registry,
|
||||
Repository = repository,
|
||||
Referrers = referrers,
|
||||
SupportsReferrersApi = result.SupportsReferrersApi
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error discovering referrers for {ImageReference}", imageReference);
|
||||
|
||||
// Record failure metric
|
||||
ExportTelemetry.ReferrerDiscoveryFailuresTotal.Add(1,
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.Registry, registry),
|
||||
new KeyValuePair<string, object?>(ExportTelemetryTags.ErrorType, ex.GetType().Name.ToLowerInvariant()));
|
||||
|
||||
return ReferrerDiscoveryResult.Failed(
|
||||
ex.Message,
|
||||
digest,
|
||||
registry,
|
||||
repository);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<byte[]?> GetReferrerContentAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
string digest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Fetching referrer content: {Registry}/{Repository}@{Digest}",
|
||||
registry, repository, digest);
|
||||
|
||||
try
|
||||
{
|
||||
return await _discovery.GetLayerContentAsync(registry, repository, digest, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch referrer content {Digest}", digest);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the probed registries cache. Useful for testing or long-running exports.
|
||||
/// </summary>
|
||||
public void ClearProbedRegistriesCache()
|
||||
{
|
||||
_probedRegistries.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an image reference into registry, repository, and digest.
|
||||
/// </summary>
|
||||
private static (string Registry, string Repository, string Digest)? ParseImageReference(string imageReference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageReference))
|
||||
return null;
|
||||
|
||||
var match = ImageReferencePattern.Match(imageReference);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
var registry = match.Groups["registry"].Value;
|
||||
var repository = match.Groups["repository"].Value;
|
||||
var separator = match.Groups["separator"].Value;
|
||||
var reference = match.Groups["reference"].Value;
|
||||
|
||||
// If the reference is a tag, we need to resolve it to a digest
|
||||
// For now, we only support direct digest references
|
||||
if (separator == "@" && reference.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (registry, repository, reference);
|
||||
}
|
||||
|
||||
// For tag references, the caller should resolve to digest first
|
||||
// We'll treat tags as potentially having referrers by using a placeholder
|
||||
if (separator == ":")
|
||||
{
|
||||
// This is a tag reference - we cannot discover referrers without resolving to digest
|
||||
// Return null to indicate the reference needs to be resolved
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a normalized artifact type tag for metrics.
|
||||
/// </summary>
|
||||
private static string GetArtifactTypeTag(string? artifactType)
|
||||
{
|
||||
if (string.IsNullOrEmpty(artifactType))
|
||||
return "unknown";
|
||||
|
||||
if (artifactType.Contains("cyclonedx", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("spdx", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("sbom", StringComparison.OrdinalIgnoreCase))
|
||||
return ArtifactTypes.Sbom;
|
||||
|
||||
if (artifactType.Contains("vex", StringComparison.OrdinalIgnoreCase))
|
||||
return ArtifactTypes.Vex;
|
||||
|
||||
if (artifactType.Contains("attestation", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("in-toto", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("dsse", StringComparison.OrdinalIgnoreCase) ||
|
||||
artifactType.Contains("provenance", StringComparison.OrdinalIgnoreCase))
|
||||
return ArtifactTypes.Attestation;
|
||||
|
||||
return "other";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering OCI referrer discovery services.
|
||||
/// </summary>
|
||||
public static class OciReferrerDiscoveryServiceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds OCI referrer discovery service to the service collection.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOciReferrerDiscoveryService(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IReferrerDiscoveryService>(sp =>
|
||||
{
|
||||
var discovery = sp.GetRequiredService<IOciReferrerDiscovery>();
|
||||
var logger = sp.GetRequiredService<ILogger<OciReferrerDiscoveryService>>();
|
||||
var fallback = sp.GetService<IOciReferrerFallback>(); // Optional
|
||||
|
||||
return new OciReferrerDiscoveryService(discovery, logger, fallback);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,42 @@ public static class ExportTelemetry
|
||||
"connections",
|
||||
"Total number of SSE connections");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of registry capability probes.
|
||||
/// Tags: registry, api_supported
|
||||
/// </summary>
|
||||
public static readonly Counter<long> RegistryCapabilitiesProbedTotal = Meter.CreateCounter<long>(
|
||||
"export_registry_capabilities_probed_total",
|
||||
"probes",
|
||||
"Total number of registry capability probes");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of referrer discovery operations by method.
|
||||
/// Tags: registry, method (native|fallback)
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ReferrerDiscoveryMethodTotal = Meter.CreateCounter<long>(
|
||||
"export_referrer_discovery_method_total",
|
||||
"discoveries",
|
||||
"Total number of referrer discovery operations by method");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of referrers discovered.
|
||||
/// Tags: registry, artifact_type
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ReferrersDiscoveredTotal = Meter.CreateCounter<long>(
|
||||
"export_referrers_discovered_total",
|
||||
"referrers",
|
||||
"Total number of referrers discovered");
|
||||
|
||||
/// <summary>
|
||||
/// Total number of referrer discovery failures.
|
||||
/// Tags: registry, error_type
|
||||
/// </summary>
|
||||
public static readonly Counter<long> ReferrerDiscoveryFailuresTotal = Meter.CreateCounter<long>(
|
||||
"export_referrer_discovery_failures_total",
|
||||
"failures",
|
||||
"Total number of referrer discovery failures");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Histograms
|
||||
@@ -291,6 +327,10 @@ public static class ExportTelemetryTags
|
||||
public const string ErrorCode = "error_code";
|
||||
public const string RunId = "run_id";
|
||||
public const string DistributionType = "distribution_type";
|
||||
public const string Registry = "registry";
|
||||
public const string ApiSupported = "api_supported";
|
||||
public const string DiscoveryMethod = "method";
|
||||
public const string ErrorType = "error_type";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -329,3 +369,12 @@ public static class ExportStatuses
|
||||
public const string Cancelled = "cancelled";
|
||||
public const string Timeout = "timeout";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Referrer discovery method values.
|
||||
/// </summary>
|
||||
public static class ReferrerDiscoveryMethods
|
||||
{
|
||||
public const string Native = "native";
|
||||
public const string Fallback = "fallback";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user