316 lines
11 KiB
C#
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";
|
|
}
|
|
}
|