doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Scanner.Contracts;
|
||||
using StellaOps.Scanner.Manifest.Models;
|
||||
using StellaOps.Scanner.Manifest.Persistence;
|
||||
|
||||
namespace StellaOps.Scanner.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Service for capturing, storing, and comparing OCI image manifest snapshots.
|
||||
/// </summary>
|
||||
public sealed class OciManifestSnapshotService : IOciManifestSnapshotService
|
||||
{
|
||||
private readonly IOciImageInspector _imageInspector;
|
||||
private readonly IManifestSnapshotRepository _repository;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<OciManifestSnapshotService> _logger;
|
||||
|
||||
public OciManifestSnapshotService(
|
||||
IOciImageInspector imageInspector,
|
||||
IManifestSnapshotRepository repository,
|
||||
ILogger<OciManifestSnapshotService> 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<OciManifestSnapshot?> 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<OciManifestSnapshot?> GetByDigestAsync(
|
||||
string manifestDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(manifestDigest))
|
||||
{
|
||||
return Task.FromResult<OciManifestSnapshot?>(null);
|
||||
}
|
||||
|
||||
return _repository.GetByDigestAsync(manifestDigest, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<OciManifestSnapshot?> GetByReferenceAsync(
|
||||
string imageReference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(imageReference))
|
||||
{
|
||||
return Task.FromResult<OciManifestSnapshot?>(null);
|
||||
}
|
||||
|
||||
return _repository.GetByReferenceAsync(imageReference, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<OciManifestSnapshot?> GetByIdAsync(
|
||||
Guid snapshotId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.GetByIdAsync(snapshotId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ManifestComparisonResult?> 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<LayerChange>();
|
||||
var added = new List<LayerChange>();
|
||||
var removed = new List<LayerChange>();
|
||||
var modified = new List<LayerChange>();
|
||||
|
||||
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<IReadOnlyList<OciManifestSnapshot>> ListByRepositoryAsync(
|
||||
string registry,
|
||||
string repository,
|
||||
int limit = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.ListByRepositoryAsync(registry, repository, limit, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<int> PruneAsync(
|
||||
DateTimeOffset olderThan,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _repository.PruneAsync(olderThan, cancellationToken);
|
||||
}
|
||||
|
||||
private static ImmutableArray<OciLayerDescriptor> BuildLayerDescriptors(ImmutableArray<LayerInfo> 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<string, OciLayerDescriptor> BuildDiffIdSet(OciManifestSnapshot snapshot)
|
||||
{
|
||||
var result = new Dictionary<string, OciLayerDescriptor>(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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user