Files
git.stella-ops.org/src/StellaOps.Concelier.Exporter.TrivyDb/TrivyDbFeedExporter.cs
2025-10-18 20:46:16 +03:00

491 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Formats.Tar;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Concelier.Exporter.Json;
using StellaOps.Concelier.Models;
using StellaOps.Concelier.Storage.Mongo.Advisories;
using StellaOps.Concelier.Storage.Mongo.Exporting;
using StellaOps.Plugin;
namespace StellaOps.Concelier.Exporter.TrivyDb;
public sealed class TrivyDbFeedExporter : IFeedExporter
{
public const string ExporterName = "trivy-db";
public const string ExporterId = "export:trivy-db";
private readonly IAdvisoryStore _advisoryStore;
private readonly IJsonExportPathResolver _pathResolver;
private readonly TrivyDbExportOptions _options;
private readonly TrivyDbPackageBuilder _packageBuilder;
private readonly TrivyDbOciWriter _ociWriter;
private readonly ExportStateManager _stateManager;
private readonly TrivyDbExportPlanner _exportPlanner;
private readonly ITrivyDbBuilder _builder;
private readonly ITrivyDbOrasPusher _orasPusher;
private readonly ILogger<TrivyDbFeedExporter> _logger;
private readonly TimeProvider _timeProvider;
private readonly string _exporterVersion;
public TrivyDbFeedExporter(
IAdvisoryStore advisoryStore,
IJsonExportPathResolver pathResolver,
IOptions<TrivyDbExportOptions> options,
TrivyDbPackageBuilder packageBuilder,
TrivyDbOciWriter ociWriter,
ExportStateManager stateManager,
TrivyDbExportPlanner exportPlanner,
ITrivyDbBuilder builder,
ITrivyDbOrasPusher orasPusher,
ILogger<TrivyDbFeedExporter> logger,
TimeProvider? timeProvider = null)
{
_advisoryStore = advisoryStore ?? throw new ArgumentNullException(nameof(advisoryStore));
_pathResolver = pathResolver ?? throw new ArgumentNullException(nameof(pathResolver));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_packageBuilder = packageBuilder ?? throw new ArgumentNullException(nameof(packageBuilder));
_ociWriter = ociWriter ?? throw new ArgumentNullException(nameof(ociWriter));
_stateManager = stateManager ?? throw new ArgumentNullException(nameof(stateManager));
_exportPlanner = exportPlanner ?? throw new ArgumentNullException(nameof(exportPlanner));
_builder = builder ?? throw new ArgumentNullException(nameof(builder));
_orasPusher = orasPusher ?? throw new ArgumentNullException(nameof(orasPusher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_exporterVersion = ExporterVersion.GetVersion(typeof(TrivyDbFeedExporter));
}
public string Name => ExporterName;
public async Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var exportedAt = _timeProvider.GetUtcNow();
var exportId = exportedAt.ToString(_options.TagFormat, CultureInfo.InvariantCulture);
var reference = $"{_options.ReferencePrefix}:{exportId}";
_logger.LogInformation("Starting Trivy DB export {ExportId}", exportId);
var jsonBuilder = new JsonExportSnapshotBuilder(_options.Json, _pathResolver);
var advisories = await LoadAdvisoriesAsync(cancellationToken).ConfigureAwait(false);
var jsonResult = await jsonBuilder.WriteAsync(advisories, exportedAt, exportId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Prepared Trivy JSON tree {ExportId} with {AdvisoryCount} advisories ({Bytes} bytes)",
exportId,
jsonResult.AdvisoryCount,
jsonResult.TotalBytes);
var manifest = jsonResult.Files
.Select(static file => new ExportFileRecord(file.RelativePath, file.Length, file.Digest))
.ToArray();
var treeDigest = ExportDigestCalculator.ComputeTreeDigest(jsonResult);
var existingState = await _stateManager.GetAsync(ExporterId, cancellationToken).ConfigureAwait(false);
var plan = _exportPlanner.CreatePlan(existingState, treeDigest, manifest);
if (plan.Mode == TrivyDbExportMode.Skip)
{
_logger.LogInformation(
"Trivy DB export {ExportId} unchanged from base {BaseExport}; skipping OCI packaging.",
exportId,
plan.BaseExportId ?? "(none)");
if (!_options.KeepWorkingTree)
{
TryDeleteDirectory(jsonResult.ExportDirectory);
}
return;
}
if (plan.Mode == TrivyDbExportMode.Delta)
{
_logger.LogInformation(
"Trivy DB export {ExportId} identified {ChangedCount} changed JSON files.",
exportId,
plan.ChangedFiles.Count);
}
var builderResult = await _builder.BuildAsync(jsonResult, exportedAt, exportId, cancellationToken).ConfigureAwait(false);
var metadataBytes = CreateMetadataJson(plan, builderResult.BuilderMetadata, treeDigest, jsonResult, exportedAt);
try
{
var package = _packageBuilder.BuildPackage(new TrivyDbPackageRequest(
metadataBytes,
builderResult.ArchivePath,
builderResult.ArchiveDigest,
builderResult.ArchiveLength,
exportedAt,
exportedAt.ToString(_options.DatabaseVersionFormat, CultureInfo.InvariantCulture)));
var destination = _options.GetExportRoot(exportId);
string? baseLayout = null;
if (plan.Mode == TrivyDbExportMode.Delta && !string.IsNullOrWhiteSpace(plan.BaseExportId))
{
baseLayout = _options.GetExportRoot(plan.BaseExportId);
}
var ociResult = await _ociWriter.WriteAsync(package, destination, reference, plan, baseLayout, cancellationToken).ConfigureAwait(false);
if (_options.Oras.Enabled && ShouldPublishToOras(plan.Mode))
{
await _orasPusher.PushAsync(destination, reference, exportId, cancellationToken).ConfigureAwait(false);
}
_logger.LogInformation(
"Trivy DB export {ExportId} wrote manifest {ManifestDigest}",
exportId,
ociResult.ManifestDigest);
var resetBaseline = plan.ResetBaseline
|| existingState is null
|| string.IsNullOrWhiteSpace(existingState.BaseExportId)
|| string.IsNullOrWhiteSpace(existingState.BaseDigest);
if (existingState is not null
&& !string.IsNullOrWhiteSpace(_options.TargetRepository)
&& !string.Equals(existingState.TargetRepository, _options.TargetRepository, StringComparison.Ordinal))
{
resetBaseline = true;
}
if (plan.Mode == TrivyDbExportMode.Full || resetBaseline)
{
await _stateManager.StoreFullExportAsync(
ExporterId,
exportId,
ociResult.ManifestDigest,
cursor: treeDigest,
targetRepository: _options.TargetRepository,
exporterVersion: _exporterVersion,
resetBaseline: resetBaseline,
manifest: plan.Manifest,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
await _stateManager.StoreDeltaExportAsync(
ExporterId,
deltaDigest: treeDigest,
cursor: treeDigest,
exporterVersion: _exporterVersion,
manifest: plan.Manifest,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
await CreateOfflineBundleAsync(destination, exportId, exportedAt, plan.Mode, cancellationToken).ConfigureAwait(false);
}
finally
{
TryDeleteDirectory(builderResult.WorkingDirectory);
}
if (!_options.KeepWorkingTree)
{
TryDeleteDirectory(jsonResult.ExportDirectory);
}
}
private async Task<IReadOnlyList<Advisory>> LoadAdvisoriesAsync(CancellationToken cancellationToken)
{
var advisories = new List<Advisory>();
await foreach (var advisory in _advisoryStore.StreamAsync(cancellationToken).ConfigureAwait(false))
{
if (advisory is null)
{
continue;
}
advisories.Add(advisory);
}
advisories.Sort(static (left, right) => string.CompareOrdinal(left.AdvisoryKey, right.AdvisoryKey));
return advisories;
}
private byte[] CreateMetadataJson(
TrivyDbExportPlan plan,
ReadOnlyMemory<byte> builderMetadata,
string treeDigest,
JsonExportResult result,
DateTimeOffset exportedAt)
{
var metadata = new TrivyMetadata
{
GeneratedAt = exportedAt.UtcDateTime,
AdvisoryCount = result.AdvisoryCount,
TreeDigest = treeDigest,
TreeBytes = result.TotalBytes,
ExporterVersion = _exporterVersion,
Builder = ParseBuilderMetadata(builderMetadata.Span),
Mode = plan.Mode switch
{
TrivyDbExportMode.Full => "full",
TrivyDbExportMode.Delta => "delta",
TrivyDbExportMode.Skip => "skip",
_ => "unknown",
},
BaseExportId = plan.BaseExportId,
BaseManifestDigest = plan.BaseManifestDigest,
ResetBaseline = plan.ResetBaseline,
Delta = plan.Mode == TrivyDbExportMode.Delta
? new DeltaMetadata(
plan.ChangedFiles.Select(static file => new DeltaFileMetadata(file.Path, file.Length, file.Digest)).ToArray(),
plan.RemovedPaths.ToArray())
: null,
};
return JsonSerializer.SerializeToUtf8Bytes(metadata, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false,
});
}
private static BuilderMetadata? ParseBuilderMetadata(ReadOnlySpan<byte> payload)
{
if (payload.IsEmpty)
{
return null;
}
try
{
return JsonSerializer.Deserialize<BuilderMetadata>(payload, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
});
}
catch
{
return null;
}
}
private async Task CreateOfflineBundleAsync(string layoutPath, string exportId, DateTimeOffset exportedAt, TrivyDbExportMode mode, CancellationToken cancellationToken)
{
if (!_options.OfflineBundle.Enabled)
{
return;
}
if (!ShouldIncludeInBundle(mode))
{
return;
}
var parent = Path.GetDirectoryName(layoutPath) ?? layoutPath;
var fileName = string.IsNullOrWhiteSpace(_options.OfflineBundle.FileName)
? $"{exportId}.offline.tar.gz"
: _options.OfflineBundle.FileName.Replace("{exportId}", exportId, StringComparison.Ordinal);
var bundlePath = Path.IsPathRooted(fileName) ? fileName : Path.Combine(parent, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(bundlePath)!);
if (File.Exists(bundlePath))
{
File.Delete(bundlePath);
}
var normalizedRoot = Path.GetFullPath(layoutPath);
var directories = Directory.GetDirectories(normalizedRoot, "*", SearchOption.AllDirectories)
.Select(dir => NormalizeTarPath(normalizedRoot, dir) + "/")
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
var files = Directory.GetFiles(normalizedRoot, "*", SearchOption.AllDirectories)
.Select(file => NormalizeTarPath(normalizedRoot, file))
.OrderBy(static path => path, StringComparer.Ordinal)
.ToArray();
await using (var archiveStream = new FileStream(
bundlePath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
options: FileOptions.Asynchronous | FileOptions.SequentialScan))
await using (var gzip = new GZipStream(archiveStream, CompressionLevel.SmallestSize, leaveOpen: true))
await using (var writer = new TarWriter(gzip, TarEntryFormat.Pax, leaveOpen: false))
{
var timestamp = exportedAt.UtcDateTime;
foreach (var directory in directories)
{
var entry = new PaxTarEntry(TarEntryType.Directory, directory)
{
ModificationTime = timestamp,
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute,
};
writer.WriteEntry(entry);
}
foreach (var relativePath in files)
{
var fullPath = Path.Combine(normalizedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
var entry = new PaxTarEntry(TarEntryType.RegularFile, relativePath)
{
ModificationTime = timestamp,
Mode = UnixFileMode.UserRead | UnixFileMode.UserWrite |
UnixFileMode.GroupRead |
UnixFileMode.OtherRead,
};
await using var source = new FileStream(
fullPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
entry.DataStream = source;
writer.WriteEntry(entry);
}
}
await ZeroGzipMtimeAsync(bundlePath, cancellationToken).ConfigureAwait(false);
var digest = await ComputeSha256Async(bundlePath, cancellationToken).ConfigureAwait(false);
var length = new FileInfo(bundlePath).Length;
_logger.LogInformation("Wrote offline bundle {BundlePath} ({Length} bytes, digest {Digest})", bundlePath, length, digest);
}
private static void TryDeleteDirectory(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch
{
// Best effort cleanup ignore failures.
}
}
private static async Task ZeroGzipMtimeAsync(string archivePath, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
archivePath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 8,
options: FileOptions.Asynchronous);
if (stream.Length < 10)
{
return;
}
stream.Position = 4;
var zeros = new byte[4];
await stream.WriteAsync(zeros, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 81920,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
var hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false);
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
}
private static string NormalizeTarPath(string root, string fullPath)
{
var relative = Path.GetRelativePath(root, fullPath);
var normalized = relative.Replace(Path.DirectorySeparatorChar, '/');
return string.IsNullOrEmpty(normalized) ? "." : normalized;
}
private bool ShouldPublishToOras(TrivyDbExportMode mode)
{
var overrides = TrivyDbExportOverrideScope.Current;
return mode switch
{
TrivyDbExportMode.Full => overrides?.PublishFull ?? _options.Oras.PublishFull,
TrivyDbExportMode.Delta => overrides?.PublishDelta ?? _options.Oras.PublishDelta,
_ => false,
};
}
private bool ShouldIncludeInBundle(TrivyDbExportMode mode)
{
var overrides = TrivyDbExportOverrideScope.Current;
return mode switch
{
TrivyDbExportMode.Full => overrides?.IncludeFull ?? _options.OfflineBundle.IncludeFull,
TrivyDbExportMode.Delta => overrides?.IncludeDelta ?? _options.OfflineBundle.IncludeDelta,
_ => false,
};
}
private sealed record DeltaMetadata(
IReadOnlyList<DeltaFileMetadata> ChangedFiles,
IReadOnlyList<string> RemovedPaths);
private sealed record DeltaFileMetadata(string Path, long Length, string Digest);
private sealed class TrivyMetadata
{
public DateTime GeneratedAt { get; set; }
public int AdvisoryCount { get; set; }
public string TreeDigest { get; set; } = string.Empty;
public long TreeBytes { get; set; }
public string ExporterVersion { get; set; } = string.Empty;
public BuilderMetadata? Builder { get; set; }
public string Mode { get; set; } = "full";
public string? BaseExportId { get; set; }
public string? BaseManifestDigest { get; set; }
public bool ResetBaseline { get; set; }
public DeltaMetadata? Delta { get; set; }
}
private sealed class BuilderMetadata
{
[JsonPropertyName("Version")]
public int Version { get; set; }
public DateTime NextUpdate { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime? DownloadedAt { get; set; }
}
}