Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Manifest/OciManifestSnapshotService.cs

316 lines
11 KiB
C#

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;
/// <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";
}
}