feat: Implement Policy Engine Evaluation Service and Cache with unit tests
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Temp commit to debug
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scanner.Storage;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.ObjectStore;
|
||||
using StellaOps.Scanner.Storage.Repositories;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
using StellaOps.Scanner.WebService.Contracts;
|
||||
using StellaOps.Scanner.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scanner.WebService.Services;
|
||||
|
||||
internal interface ISurfacePointerService
|
||||
{
|
||||
Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class SurfacePointerService : ISurfacePointerService
|
||||
{
|
||||
private static readonly JsonSerializerOptions ManifestSerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
private readonly ISurfaceEnvironment _surfaceEnvironment;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<SurfacePointerService> _logger;
|
||||
|
||||
public SurfacePointerService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
ISurfaceEnvironment surfaceEnvironment,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<SurfacePointerService> logger)
|
||||
{
|
||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_surfaceEnvironment = surfaceEnvironment ?? throw new ArgumentNullException(nameof(surfaceEnvironment));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<SurfacePointersDto?> TryBuildAsync(string imageDigest, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageDigest))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalizedDigest = imageDigest.Trim();
|
||||
|
||||
List<LinkDocument> links;
|
||||
try
|
||||
{
|
||||
links = await _linkRepository.ListBySourceAsync(LinkSourceType.Image, normalizedDigest, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load link documents for digest {Digest}.", normalizedDigest);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (links.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue ?? new ScannerWebServiceOptions();
|
||||
var artifactStore = options.ArtifactStore ?? new ScannerWebServiceOptions.ArtifactStoreOptions();
|
||||
var bucket = ResolveBucket(artifactStore);
|
||||
var rootPrefix = artifactStore.RootPrefix ?? ScannerStorageDefaults.DefaultRootPrefix;
|
||||
var tenant = _surfaceEnvironment.Settings.Tenant;
|
||||
var generatedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var artifacts = ImmutableArray.CreateBuilder<SurfaceManifestArtifact>();
|
||||
|
||||
foreach (var link in links)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
ArtifactDocument? artifactDocument;
|
||||
try
|
||||
{
|
||||
artifactDocument = await _artifactRepository.GetAsync(link.ArtifactId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load artifact document {ArtifactId}.", link.ArtifactId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (artifactDocument is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var objectKey = ArtifactObjectKeyBuilder.Build(
|
||||
artifactDocument.Type,
|
||||
artifactDocument.Format,
|
||||
artifactDocument.BytesSha256,
|
||||
rootPrefix);
|
||||
var uri = BuildCasUri(bucket, objectKey);
|
||||
var (kind, view) = MapKindAndView(artifactDocument);
|
||||
var format = MapFormat(artifactDocument.Format);
|
||||
var artifact = new SurfaceManifestArtifact
|
||||
{
|
||||
Kind = kind,
|
||||
Uri = uri,
|
||||
Digest = artifactDocument.BytesSha256,
|
||||
MediaType = artifactDocument.MediaType,
|
||||
Format = format,
|
||||
SizeBytes = artifactDocument.SizeBytes,
|
||||
View = view
|
||||
};
|
||||
artifacts.Add(artifact);
|
||||
}
|
||||
|
||||
if (artifacts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var orderedArtifacts = artifacts.OrderBy(a => a.Kind, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Format, StringComparer.Ordinal)
|
||||
.ThenBy(a => a.Digest, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
var manifest = new SurfaceManifestDocument
|
||||
{
|
||||
Tenant = tenant,
|
||||
ImageDigest = normalizedDigest,
|
||||
GeneratedAt = generatedAt,
|
||||
Artifacts = orderedArtifacts
|
||||
};
|
||||
|
||||
var manifestJson = JsonSerializer.SerializeToUtf8Bytes(manifest, ManifestSerializerOptions);
|
||||
var manifestDigest = ComputeDigest(manifestJson);
|
||||
var manifestUri = BuildManifestUri(bucket, rootPrefix, tenant, manifestDigest);
|
||||
|
||||
return new SurfacePointersDto
|
||||
{
|
||||
Tenant = tenant,
|
||||
GeneratedAt = generatedAt,
|
||||
ManifestDigest = manifestDigest,
|
||||
ManifestUri = manifestUri,
|
||||
Manifest = manifest with { GeneratedAt = generatedAt }
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveBucket(ScannerWebServiceOptions.ArtifactStoreOptions artifactStore)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artifactStore.Bucket))
|
||||
{
|
||||
return artifactStore.Bucket.Trim();
|
||||
}
|
||||
|
||||
return ScannerStorageDefaults.DefaultBucketName;
|
||||
}
|
||||
|
||||
private static string MapFormat(ArtifactDocumentFormat format)
|
||||
=> format switch
|
||||
{
|
||||
ArtifactDocumentFormat.CycloneDxJson => "cdx-json",
|
||||
ArtifactDocumentFormat.CycloneDxProtobuf => "cdx-protobuf",
|
||||
ArtifactDocumentFormat.SpdxJson => "spdx-json",
|
||||
ArtifactDocumentFormat.BomIndex => "bom-index",
|
||||
ArtifactDocumentFormat.DsseJson => "dsse-json",
|
||||
_ => format.ToString().ToLowerInvariant()
|
||||
};
|
||||
|
||||
private static (string Kind, string? View) MapKindAndView(ArtifactDocument document)
|
||||
{
|
||||
if (document.Type == ArtifactDocumentType.ImageBom)
|
||||
{
|
||||
var view = ResolveView(document.MediaType);
|
||||
var kind = string.Equals(view, "usage", StringComparison.OrdinalIgnoreCase)
|
||||
? "sbom-usage"
|
||||
: "sbom-inventory";
|
||||
return (kind, view);
|
||||
}
|
||||
|
||||
return document.Type switch
|
||||
{
|
||||
ArtifactDocumentType.LayerBom => ("layer-sbom", null),
|
||||
ArtifactDocumentType.Diff => ("diff", null),
|
||||
ArtifactDocumentType.Attestation => ("attestation", null),
|
||||
ArtifactDocumentType.Index => ("bom-index", null),
|
||||
_ => (document.Type.ToString().ToLowerInvariant(), null)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveView(string mediaType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mediaType.Contains("view=usage", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "usage";
|
||||
}
|
||||
|
||||
if (mediaType.Contains("view=inventory", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "inventory";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildCasUri(string bucket, string key)
|
||||
{
|
||||
var normalizedKey = string.IsNullOrWhiteSpace(key) ? string.Empty : key.Trim().TrimStart('/');
|
||||
return $"cas://{bucket}/{normalizedKey}";
|
||||
}
|
||||
|
||||
private static string BuildManifestUri(string bucket, string rootPrefix, string tenant, string manifestDigest)
|
||||
{
|
||||
var (algorithm, digestValue) = SplitDigest(manifestDigest);
|
||||
var prefix = string.IsNullOrWhiteSpace(rootPrefix)
|
||||
? "surface/manifests"
|
||||
: $"{TrimTrailingSlash(rootPrefix)}/surface/manifests";
|
||||
|
||||
var head = digestValue.Length >= 4
|
||||
? $"{digestValue[..2]}/{digestValue[2..4]}"
|
||||
: digestValue;
|
||||
|
||||
var key = $"{prefix}/{tenant}/{algorithm}/{head}/{digestValue}.json";
|
||||
return $"cas://{bucket}/{key}";
|
||||
}
|
||||
|
||||
private static (string Algorithm, string Digest) SplitDigest(string digest)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(digest))
|
||||
{
|
||||
return ("sha256", digest ?? string.Empty);
|
||||
}
|
||||
|
||||
var parts = digest.Split(':', 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
|
||||
return ("sha256", digest);
|
||||
}
|
||||
|
||||
private static string TrimTrailingSlash(string value)
|
||||
=> string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Trim().TrimEnd('/');
|
||||
|
||||
private static string ComputeDigest(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
if (!SHA256.TryHashData(payload, hash, out _))
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
hash = sha.ComputeHash(payload.ToArray());
|
||||
}
|
||||
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user