using System.Collections.Immutable; using Microsoft.Extensions.Logging; using StellaOps.Scanner.Contracts; using StellaOps.Scanner.Manifest.Models; using StellaOps.Scanner.Manifest.Persistence; using StellaOps.Scanner.Storage.Oci; namespace StellaOps.Scanner.Manifest; /// /// Service for capturing, storing, and comparing OCI image manifest snapshots. /// public sealed class OciManifestSnapshotService : IOciManifestSnapshotService { private readonly IOciImageInspector _imageInspector; private readonly IManifestSnapshotRepository _repository; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public OciManifestSnapshotService( IOciImageInspector imageInspector, IManifestSnapshotRepository repository, ILogger logger, TimeProvider? timeProvider = null) { _imageInspector = imageInspector ?? throw new ArgumentNullException(nameof(imageInspector)); _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _timeProvider = timeProvider ?? TimeProvider.System; } public async Task CaptureAsync( string imageReference, ManifestCaptureOptions? options = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(imageReference)) { return null; } options ??= new ManifestCaptureOptions(); _logger.LogDebug("Capturing manifest snapshot for {ImageReference}", imageReference); var inspectionOptions = new ImageInspectionOptions { IncludeLayers = true, ResolveIndex = true, PlatformFilter = options.PlatformFilter, Timeout = options.Timeout }; var inspection = await _imageInspector.InspectAsync(imageReference, inspectionOptions, cancellationToken) .ConfigureAwait(false); if (inspection is null) { _logger.LogWarning("Failed to inspect image {ImageReference}", imageReference); return null; } var platform = inspection.Platforms.FirstOrDefault(); if (platform is null) { _logger.LogWarning("No platforms found for image {ImageReference}", imageReference); return null; } var layers = BuildLayerDescriptors(platform.Layers); var snapshot = new OciManifestSnapshot { Id = Guid.NewGuid(), ImageReference = imageReference, Registry = inspection.Registry, Repository = inspection.Repository, Tag = ExtractTag(imageReference), ManifestDigest = inspection.ResolvedDigest, ConfigDigest = platform.ConfigDigest, MediaType = platform.ManifestMediaType, Layers = layers, DiffIds = layers.Select(l => l.DiffId).ToImmutableArray(), Platform = new OciPlatformInfo { Os = platform.Os, Architecture = platform.Architecture, Variant = platform.Variant, OsVersion = platform.OsVersion }, TotalSize = platform.TotalSize, CapturedAt = _timeProvider.GetUtcNow(), SnapshotVersion = GetSnapshotVersion() }; if (options.Persist) { await _repository.UpsertAsync(snapshot, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Captured manifest snapshot for {ImageReference} with {LayerCount} layers", imageReference, snapshot.LayerCount); } return snapshot; } public Task GetByDigestAsync( string manifestDigest, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(manifestDigest)) { return Task.FromResult(null); } return _repository.GetByDigestAsync(manifestDigest, cancellationToken); } public Task GetByReferenceAsync( string imageReference, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(imageReference)) { return Task.FromResult(null); } return _repository.GetByReferenceAsync(imageReference, cancellationToken); } public Task GetByIdAsync( Guid snapshotId, CancellationToken cancellationToken = default) { return _repository.GetByIdAsync(snapshotId, cancellationToken); } public async Task CompareAsync( string oldManifestDigest, string newManifestDigest, CancellationToken cancellationToken = default) { var oldSnapshot = await GetByDigestAsync(oldManifestDigest, cancellationToken).ConfigureAwait(false); if (oldSnapshot is null) { _logger.LogWarning("Old manifest {Digest} not found for comparison", oldManifestDigest); return null; } var newSnapshot = await GetByDigestAsync(newManifestDigest, cancellationToken).ConfigureAwait(false); if (newSnapshot is null) { _logger.LogWarning("New manifest {Digest} not found for comparison", newManifestDigest); return null; } return Compare(oldSnapshot, newSnapshot); } public ManifestComparisonResult Compare(OciManifestSnapshot oldSnapshot, OciManifestSnapshot newSnapshot) { ArgumentNullException.ThrowIfNull(oldSnapshot); ArgumentNullException.ThrowIfNull(newSnapshot); var oldDiffIds = BuildDiffIdSet(oldSnapshot); var newDiffIds = BuildDiffIdSet(newSnapshot); var unchanged = new List(); var added = new List(); var removed = new List(); var modified = new List(); foreach (var oldLayer in oldSnapshot.Layers) { var diffId = oldLayer.DiffId; if (string.IsNullOrWhiteSpace(diffId)) { continue; } if (newDiffIds.TryGetValue(diffId, out var newLayer)) { unchanged.Add(new LayerChange { ChangeType = LayerChangeType.Unchanged, OldLayer = oldLayer, NewLayer = newLayer }); } else { var newLayerAtIndex = newSnapshot.Layers.FirstOrDefault(l => l.LayerIndex == oldLayer.LayerIndex); if (newLayerAtIndex is not null && !string.IsNullOrWhiteSpace(newLayerAtIndex.DiffId)) { modified.Add(new LayerChange { ChangeType = LayerChangeType.Modified, OldLayer = oldLayer, NewLayer = newLayerAtIndex }); } else { removed.Add(new LayerChange { ChangeType = LayerChangeType.Removed, OldLayer = oldLayer, NewLayer = null }); } } } foreach (var newLayer in newSnapshot.Layers) { var diffId = newLayer.DiffId; if (string.IsNullOrWhiteSpace(diffId)) { continue; } if (!oldDiffIds.ContainsKey(diffId) && !modified.Any(m => m.NewLayer?.LayerIndex == newLayer.LayerIndex)) { added.Add(new LayerChange { ChangeType = LayerChangeType.Added, OldLayer = null, NewLayer = newLayer }); } } return new ManifestComparisonResult { OldSnapshot = oldSnapshot, NewSnapshot = newSnapshot, UnchangedLayers = unchanged.OrderBy(l => l.LayerIndex).ToImmutableArray(), AddedLayers = added.OrderBy(l => l.LayerIndex).ToImmutableArray(), RemovedLayers = removed.OrderBy(l => l.LayerIndex).ToImmutableArray(), ModifiedLayers = modified.OrderBy(l => l.LayerIndex).ToImmutableArray() }; } public Task> ListByRepositoryAsync( string registry, string repository, int limit = 100, CancellationToken cancellationToken = default) { return _repository.ListByRepositoryAsync(registry, repository, limit, cancellationToken); } public Task PruneAsync( DateTimeOffset olderThan, CancellationToken cancellationToken = default) { return _repository.PruneAsync(olderThan, cancellationToken); } private static ImmutableArray BuildLayerDescriptors(ImmutableArray layers) { return layers.Select((layer, index) => new OciLayerDescriptor { Digest = layer.Digest, DiffId = null, Size = layer.Size, MediaType = layer.MediaType, LayerIndex = index, Annotations = layer.Annotations }).ToImmutableArray(); } private static Dictionary BuildDiffIdSet(OciManifestSnapshot snapshot) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var layer in snapshot.Layers) { if (!string.IsNullOrWhiteSpace(layer.DiffId) && !result.ContainsKey(layer.DiffId)) { result[layer.DiffId] = layer; } } return result; } private static string? ExtractTag(string imageReference) { if (imageReference.Contains('@')) { var atIndex = imageReference.IndexOf('@'); var beforeAt = imageReference[..atIndex]; var colonIndex = beforeAt.LastIndexOf(':'); if (colonIndex > 0 && !beforeAt[(colonIndex + 1)..].Contains('/')) { return beforeAt[(colonIndex + 1)..]; } return null; } var lastColon = imageReference.LastIndexOf(':'); if (lastColon > 0 && !imageReference[lastColon..].Contains('/')) { return imageReference[(lastColon + 1)..]; } return null; } private static string GetSnapshotVersion() { return typeof(OciManifestSnapshotService).Assembly.GetName().Version?.ToString() ?? "1.0.0"; } }