Implement Advisory Canonicalization and Backfill Migration
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added AdvisoryCanonicalizer for canonicalizing advisory identifiers. - Created EnsureAdvisoryCanonicalKeyBackfillMigration to populate advisory_key and links in advisory_raw documents. - Introduced FileSurfaceManifestStore for managing surface manifests with file system backing. - Developed ISurfaceManifestReader and ISurfaceManifestWriter interfaces for reading and writing manifests. - Implemented SurfaceManifestPathBuilder for constructing paths and URIs for surface manifests. - Added tests for FileSurfaceManifestStore to ensure correct functionality and deterministic behavior. - Updated documentation for new features and migration steps.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Raw;
|
||||
|
||||
public static class AdvisoryCanonicalizer
|
||||
{
|
||||
private static readonly ImmutableArray<string> PrimarySchemePriority = new[]
|
||||
{
|
||||
AliasSchemes.Cve,
|
||||
AliasSchemes.Ghsa,
|
||||
AliasSchemes.OsV,
|
||||
AliasSchemes.Bdu,
|
||||
AliasSchemes.Jvn,
|
||||
AliasSchemes.Jvndb,
|
||||
AliasSchemes.Rhsa,
|
||||
AliasSchemes.Usn,
|
||||
AliasSchemes.Dsa,
|
||||
AliasSchemes.SuseSu,
|
||||
AliasSchemes.Icsa,
|
||||
AliasSchemes.Msrc,
|
||||
AliasSchemes.CiscoSa,
|
||||
AliasSchemes.OracleCpu,
|
||||
AliasSchemes.Apsb,
|
||||
AliasSchemes.Apa,
|
||||
AliasSchemes.AppleHt,
|
||||
AliasSchemes.Vmsa,
|
||||
AliasSchemes.Vu,
|
||||
AliasSchemes.ChromiumPost,
|
||||
}.ToImmutableArray();
|
||||
|
||||
private const string PrimaryScheme = "PRIMARY";
|
||||
private const string UnscopedScheme = "UNSCOPED";
|
||||
|
||||
public static AdvisoryCanonicalizationResult Canonicalize(
|
||||
RawIdentifiers identifiers,
|
||||
RawSourceMetadata source,
|
||||
RawUpstreamMetadata upstream)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(identifiers);
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(upstream);
|
||||
|
||||
var candidates = new List<(string Scheme, string Value)>();
|
||||
|
||||
void AddCandidate(string? rawValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawValue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var trimmed = rawValue.Trim();
|
||||
if (AliasSchemeRegistry.TryNormalize(trimmed, out var normalized, out var scheme))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(normalized) && !string.IsNullOrEmpty(scheme))
|
||||
{
|
||||
candidates.Add((scheme, normalized));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
candidates.Add((UnscopedScheme, trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
AddCandidate(identifiers.PrimaryId);
|
||||
|
||||
if (!identifiers.Aliases.IsDefaultOrEmpty)
|
||||
{
|
||||
foreach (var alias in identifiers.Aliases)
|
||||
{
|
||||
AddCandidate(alias);
|
||||
}
|
||||
}
|
||||
|
||||
var unique = new Dictionary<(string Scheme, string Value), RawLink>(CandidateComparer.Instance);
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var key = (candidate.Scheme, candidate.Value);
|
||||
if (!unique.ContainsKey(key))
|
||||
{
|
||||
unique[key] = new RawLink(candidate.Scheme, candidate.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var advisoryKey = SelectCanonicalKey(unique.Keys, identifiers, source, upstream);
|
||||
unique[(PrimaryScheme, advisoryKey)] = new RawLink(PrimaryScheme, advisoryKey);
|
||||
|
||||
var links = unique.Values
|
||||
.OrderBy(static link => link.Scheme, StringComparer.Ordinal)
|
||||
.ThenBy(static link => link.Value, StringComparer.Ordinal)
|
||||
.ToImmutableArray();
|
||||
|
||||
return new AdvisoryCanonicalizationResult(advisoryKey, links);
|
||||
}
|
||||
|
||||
private static string SelectCanonicalKey(
|
||||
IEnumerable<(string Scheme, string Value)> candidates,
|
||||
RawIdentifiers identifiers,
|
||||
RawSourceMetadata source,
|
||||
RawUpstreamMetadata upstream)
|
||||
{
|
||||
foreach (var preferredScheme in PrimarySchemePriority)
|
||||
{
|
||||
var match = candidates.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Scheme, preferredScheme, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrEmpty(match.Value))
|
||||
{
|
||||
return match.Value;
|
||||
}
|
||||
}
|
||||
|
||||
var firstCandidate = candidates.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(firstCandidate.Value))
|
||||
{
|
||||
return firstCandidate.Value;
|
||||
}
|
||||
|
||||
var fallbackValue = identifiers.PrimaryId;
|
||||
if (string.IsNullOrWhiteSpace(fallbackValue))
|
||||
{
|
||||
fallbackValue = upstream.UpstreamId;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fallbackValue))
|
||||
{
|
||||
fallbackValue = upstream.ContentHash;
|
||||
}
|
||||
|
||||
fallbackValue = (fallbackValue ?? "unknown").Trim();
|
||||
var vendor = NormalizeVendor(source.Vendor);
|
||||
return $"{vendor}:{NormalizeFallbackValue(fallbackValue)}";
|
||||
}
|
||||
|
||||
private static string NormalizeVendor(string vendor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendor))
|
||||
{
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
return vendor.Trim().ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeFallbackValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var trimmed = value.Trim();
|
||||
var builder = new char[trimmed.Length];
|
||||
var index = 0;
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
builder[index++] = '-';
|
||||
}
|
||||
else
|
||||
{
|
||||
builder[index++] = char.ToUpperInvariant(ch);
|
||||
}
|
||||
}
|
||||
|
||||
return new string(builder, 0, index);
|
||||
}
|
||||
|
||||
private sealed class CandidateComparer : IEqualityComparer<(string Scheme, string Value)>
|
||||
{
|
||||
public static CandidateComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals((string Scheme, string Value) x, (string Scheme, string Value) y)
|
||||
=> string.Equals(x.Scheme, y.Scheme, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(x.Value, y.Value, StringComparison.Ordinal);
|
||||
|
||||
public int GetHashCode((string Scheme, string Value) obj)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var schemeHash = obj.Scheme?.ToUpperInvariant().GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
var valueHash = obj.Value?.GetHashCode(StringComparison.Ordinal) ?? 0;
|
||||
return (schemeHash * 397) ^ valueHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AdvisoryCanonicalizationResult(string AdvisoryKey, ImmutableArray<RawLink> Links);
|
||||
@@ -8,7 +8,8 @@ using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Aoc;
|
||||
using StellaOps.Concelier.Core.Aoc;
|
||||
using StellaOps.Concelier.Core.Linksets;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
using StellaOps.Concelier.Models;
|
||||
|
||||
namespace StellaOps.Concelier.Core.Raw;
|
||||
|
||||
@@ -99,12 +100,37 @@ internal sealed class AdvisoryRawService : IAdvisoryRawService
|
||||
return _repository.FindByIdAsync(normalizedTenant, normalizedId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return _repository.QueryAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
return _repository.QueryAsync(options, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(advisoryKey);
|
||||
|
||||
var normalizedTenant = NormalizeTenant(tenant);
|
||||
var searchValues = BuildAdvisoryKeySearchValues(advisoryKey);
|
||||
if (searchValues.Length == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<AdvisoryRawRecord>>(Array.Empty<AdvisoryRawRecord>());
|
||||
}
|
||||
|
||||
var vendors = NormalizeSourceVendors(sourceVendors);
|
||||
|
||||
return _repository.FindByAdvisoryKeyAsync(
|
||||
normalizedTenant,
|
||||
searchValues,
|
||||
vendors,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
@@ -249,16 +275,20 @@ internal sealed class AdvisoryRawService : IAdvisoryRawService
|
||||
var upstream = NormalizeUpstream(document.Upstream);
|
||||
var content = NormalizeContent(document.Content);
|
||||
var identifiers = NormalizeIdentifiers(document.Identifiers);
|
||||
var linkset = NormalizeLinkset(document.Linkset);
|
||||
|
||||
return new AdvisoryRawDocument(
|
||||
tenant,
|
||||
source,
|
||||
upstream,
|
||||
content,
|
||||
identifiers,
|
||||
linkset,
|
||||
Supersedes: null);
|
||||
var linkset = NormalizeLinkset(document.Linkset);
|
||||
var canonical = AdvisoryCanonicalizer.Canonicalize(identifiers, source, upstream);
|
||||
var links = canonical.Links.IsDefault ? ImmutableArray<RawLink>.Empty : canonical.Links;
|
||||
|
||||
return new AdvisoryRawDocument(
|
||||
tenant,
|
||||
source,
|
||||
upstream,
|
||||
content,
|
||||
identifiers,
|
||||
linkset,
|
||||
canonical.AdvisoryKey,
|
||||
links.IsDefaultOrEmpty ? ImmutableArray<RawLink>.Empty : links,
|
||||
Supersedes: null);
|
||||
}
|
||||
|
||||
private static RawSourceMetadata NormalizeSource(RawSourceMetadata source)
|
||||
@@ -377,6 +407,27 @@ internal sealed class AdvisoryRawService : IAdvisoryRawService
|
||||
};
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeSourceVendors(IReadOnlyCollection<string> sourceVendors)
|
||||
{
|
||||
if (sourceVendors is null || sourceVendors.Count == 0)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<string>(sourceVendors.Count);
|
||||
foreach (var vendor in sourceVendors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(vendor))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(NormalizeSourceVendor(vendor));
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<string>.Empty : builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> NormalizeStringArray(ImmutableArray<string> values)
|
||||
{
|
||||
if (values.IsDefaultOrEmpty)
|
||||
@@ -419,14 +470,39 @@ internal sealed class AdvisoryRawService : IAdvisoryRawService
|
||||
string.IsNullOrWhiteSpace(reference.Source) ? null : reference.Source.Trim()));
|
||||
}
|
||||
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private JsonElement ToJsonElement(AdvisoryRawDocument document)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(document);
|
||||
using var jsonDocument = System.Text.Json.JsonDocument.Parse(json);
|
||||
return jsonDocument.RootElement.Clone();
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> BuildAdvisoryKeySearchValues(string advisoryKey)
|
||||
{
|
||||
var trimmed = advisoryKey?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
var set = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
trimmed,
|
||||
trimmed.ToUpperInvariant()
|
||||
};
|
||||
|
||||
if (AliasSchemeRegistry.TryNormalize(trimmed, out var normalized, out _)
|
||||
&& !string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
set.Add(normalized);
|
||||
}
|
||||
|
||||
return set.Count == 0
|
||||
? ImmutableArray<string>.Empty
|
||||
: ImmutableArray.CreateRange(set);
|
||||
}
|
||||
|
||||
private JsonElement ToJsonElement(AdvisoryRawDocument document)
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(document);
|
||||
using var jsonDocument = System.Text.Json.JsonDocument.Parse(json);
|
||||
return jsonDocument.RootElement.Clone();
|
||||
}
|
||||
|
||||
private sealed class VerificationAggregation
|
||||
|
||||
@@ -23,13 +23,22 @@ public interface IAdvisoryRawRepository
|
||||
/// <summary>
|
||||
/// Queries raw documents using the supplied filter/paging options.
|
||||
/// </summary>
|
||||
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates raw advisory documents for verification runs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
|
||||
string tenant,
|
||||
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all raw documents associated with the supplied advisory key (or alias) for a tenant.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> searchValues,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates raw advisory documents for verification runs.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
|
||||
string tenant,
|
||||
DateTimeOffset since,
|
||||
DateTimeOffset until,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
|
||||
@@ -7,13 +7,19 @@ namespace StellaOps.Concelier.Core.Raw;
|
||||
/// </summary>
|
||||
public interface IAdvisoryRawService
|
||||
{
|
||||
Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken);
|
||||
Task<AdvisoryRawUpsertResult> IngestAsync(AdvisoryRawDocument document, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawRecord?> FindByIdAsync(string tenant, string id, CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawQueryResult> QueryAsync(AdvisoryRawQueryOptions options, CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
||||
string tenant,
|
||||
string advisoryKey,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<AdvisoryRawVerificationResult> VerifyAsync(AdvisoryRawVerificationRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
| Task | Owner(s) | Depends on | Notes |
|
||||
|---|---|---|---|
|
||||
|MERGE-LNM-21-001 Migration plan authoring|BE-Merge, Architecture Guild|CONCELIER-LNM-21-101|**DONE (2025-11-03)** – Authored `docs/migration/no-merge.md` with rollout phases, backfill/validation checklists, rollback guidance, and ownership matrix for the Link-Not-Merge cutover.|
|
||||
|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DOING (2025-11-07)** – Defaulted `concelier:features:noMergeEnabled` to `true`, added merge job allowlist gate, and began rewiring guard/tier tests; follow-up work required to restore Concelier WebService test suite before declaring completion.<br>2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.<br>2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.<br>2025-11-07 03:25Z: Default-on toggle + job gating break existing Concelier WebService tests; guard + seed fixes pending to unblock ingest/mirror suites.<br>2025-11-07 07:05Z: Added ingest logging + test log dumps to trace upstream hash loss; still chasing why Minimal API binding strips `upstream.contentHash` before the guard runs.|
|
||||
|MERGE-LNM-21-002 Merge service deprecation|BE-Merge|MERGE-LNM-21-001|**DONE (2025-11-07)** – Feature flag now defaults to Link-Not-Merge mode (`NoMergeEnabled=true`) across options/config, analyzers enforce deprecation, and WebService option tests cover the regression; dotnet CLI validation still queued for a workstation with preview SDK.<br>2025-11-05 14:42Z: Implemented `concelier:features:noMergeEnabled` gate, merge job allowlist checks, `[Obsolete]` markings, and analyzer scaffolding to steer consumers toward linkset APIs.<br>2025-11-06 16:10Z: Introduced Roslyn analyzer (`CONCELIER0002`) referenced by Concelier WebService + tests, documented suppression guidance, and updated migration playbook.<br>2025-11-07 03:25Z: Default-on toggle + job gating surfacing ingestion test brittleness; guard logs capture requests missing `upstream.contentHash`.<br>2025-11-07 19:45Z: Set `ConcelierOptions.Features.NoMergeEnabled` default to `true`, added regression coverage (`Features_NoMergeEnabled_DefaultsToTrue`), and rechecked ingest helpers to carry canonical links before closing the task.|
|
||||
> 2025-11-03: Catalogued call sites (WebService Program `AddMergeModule`, built-in job registration `merge:reconcile`, `MergeReconcileJob`) and confirmed unit tests are the only direct `MergeAsync` callers; next step is to define analyzer + replacement observability coverage.
|
||||
|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|Replace merge determinism suites with observation/linkset regression tests verifying no data mutation and conflicts remain visible.|
|
||||
|MERGE-LNM-21-003 Determinism/test updates|QA Guild, BE-Merge|MERGE-LNM-21-002|**DOING (2025-11-07)** – Replacing legacy merge determinism harness with observation/linkset regression plan; tracking scenarios in `docs/dev/lnm-determinism-tests.md` before porting fixtures.<br>2025-11-07 20:05Z: Ported merge determinism fixture into `AdvisoryObservationFactoryTests.Create_IsDeterministicAcrossRuns` and removed the redundant merge integration test.|
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.RawModels;
|
||||
|
||||
public sealed record AdvisoryRawDocument(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("source")] RawSourceMetadata Source,
|
||||
[property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream,
|
||||
[property: JsonPropertyName("content")] RawContent Content,
|
||||
[property: JsonPropertyName("identifiers")] RawIdentifiers Identifiers,
|
||||
[property: JsonPropertyName("linkset")] RawLinkset Linkset,
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes = null)
|
||||
{
|
||||
public AdvisoryRawDocument WithSupersedes(string supersedes)
|
||||
=> this with { Supersedes = supersedes };
|
||||
}
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Concelier.RawModels;
|
||||
|
||||
public sealed record AdvisoryRawDocument(
|
||||
[property: JsonPropertyName("tenant")] string Tenant,
|
||||
[property: JsonPropertyName("source")] RawSourceMetadata Source,
|
||||
[property: JsonPropertyName("upstream")] RawUpstreamMetadata Upstream,
|
||||
[property: JsonPropertyName("content")] RawContent Content,
|
||||
[property: JsonPropertyName("identifiers")] RawIdentifiers Identifiers,
|
||||
[property: JsonPropertyName("linkset")] RawLinkset Linkset,
|
||||
[property: JsonPropertyName("advisory_key")] string AdvisoryKey = "",
|
||||
[property: JsonPropertyName("links")] ImmutableArray<RawLink> Links = default,
|
||||
[property: JsonPropertyName("supersedes")] string? Supersedes = null)
|
||||
{
|
||||
public AdvisoryRawDocument WithSupersedes(string supersedes)
|
||||
=> this with { Supersedes = supersedes };
|
||||
}
|
||||
|
||||
public sealed record RawSourceMetadata(
|
||||
[property: JsonPropertyName("vendor")] string Vendor,
|
||||
@@ -49,10 +51,10 @@ public sealed record RawIdentifiers(
|
||||
[property: JsonPropertyName("aliases")] ImmutableArray<string> Aliases,
|
||||
[property: JsonPropertyName("primary")] string PrimaryId);
|
||||
|
||||
public sealed record RawLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public ImmutableArray<string> Aliases { get; init; } = ImmutableArray<string>.Empty;
|
||||
public sealed record RawLinkset
|
||||
{
|
||||
[JsonPropertyName("aliases")]
|
||||
public ImmutableArray<string> Aliases { get; init; } = ImmutableArray<string>.Empty;
|
||||
|
||||
[JsonPropertyName("purls")]
|
||||
public ImmutableArray<string> PackageUrls { get; init; } = ImmutableArray<string>.Empty;
|
||||
@@ -68,9 +70,13 @@ public sealed record RawLinkset
|
||||
|
||||
[JsonPropertyName("notes")]
|
||||
public ImmutableDictionary<string, string> Notes { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
}
|
||||
|
||||
public sealed record RawReference(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("source")] string? Source = null);
|
||||
}
|
||||
|
||||
public sealed record RawReference(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("source")] string? Source = null);
|
||||
|
||||
public sealed record RawLink(
|
||||
[property: JsonPropertyName("scheme")] string Scheme,
|
||||
[property: JsonPropertyName("value")] string Value);
|
||||
|
||||
@@ -5,18 +5,21 @@ namespace StellaOps.Concelier.RawModels;
|
||||
|
||||
public static class RawDocumentFactory
|
||||
{
|
||||
public static AdvisoryRawDocument CreateAdvisory(
|
||||
string tenant,
|
||||
RawSourceMetadata source,
|
||||
RawUpstreamMetadata upstream,
|
||||
RawContent content,
|
||||
RawIdentifiers identifiers,
|
||||
RawLinkset linkset,
|
||||
string? supersedes = null)
|
||||
{
|
||||
var clonedContent = content with { Raw = Clone(content.Raw) };
|
||||
return new AdvisoryRawDocument(tenant, source, upstream, clonedContent, identifiers, linkset, supersedes);
|
||||
}
|
||||
public static AdvisoryRawDocument CreateAdvisory(
|
||||
string tenant,
|
||||
RawSourceMetadata source,
|
||||
RawUpstreamMetadata upstream,
|
||||
RawContent content,
|
||||
RawIdentifiers identifiers,
|
||||
RawLinkset linkset,
|
||||
string advisoryKey,
|
||||
ImmutableArray<RawLink> links,
|
||||
string? supersedes = null)
|
||||
{
|
||||
var clonedContent = content with { Raw = Clone(content.Raw) };
|
||||
var normalizedLinks = links.IsDefault ? ImmutableArray<RawLink>.Empty : links;
|
||||
return new AdvisoryRawDocument(tenant, source, upstream, clonedContent, identifiers, linkset, advisoryKey, normalizedLinks, supersedes);
|
||||
}
|
||||
|
||||
public static VexRawDocument CreateVex(
|
||||
string tenant,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.RawModels;
|
||||
|
||||
namespace StellaOps.Concelier.Storage.Mongo.Migrations;
|
||||
|
||||
public sealed class EnsureAdvisoryCanonicalKeyBackfillMigration : IMongoMigration
|
||||
{
|
||||
public string Id => "2025-11-07-advisory-canonical-key";
|
||||
|
||||
public string Description => "Populate advisory_key and links for advisory_raw documents.";
|
||||
|
||||
public async Task ApplyAsync(IMongoDatabase database, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
|
||||
var collection = database.GetCollection<BsonDocument>(MongoStorageDefaults.Collections.AdvisoryRaw);
|
||||
var filter = Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("advisory_key", false),
|
||||
Builders<BsonDocument>.Filter.Type("advisory_key", BsonType.Null),
|
||||
Builders<BsonDocument>.Filter.Eq("advisory_key", string.Empty),
|
||||
Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.Exists("links", false),
|
||||
Builders<BsonDocument>.Filter.Type("links", BsonType.Null)));
|
||||
|
||||
using var cursor = await collection.Find(filter).ToCursorAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await cursor.MoveNextAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
foreach (var document in cursor.Current)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!document.TryGetValue("_id", out var idValue) || idValue.IsBsonNull)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var source = ParseSource(document.GetValue("source", new BsonDocument()).AsBsonDocument);
|
||||
var upstream = ParseUpstream(document.GetValue("upstream", new BsonDocument()).AsBsonDocument);
|
||||
var identifiers = ParseIdentifiers(document.GetValue("identifiers", new BsonDocument()).AsBsonDocument);
|
||||
|
||||
var canonical = AdvisoryCanonicalizer.Canonicalize(identifiers, source, upstream);
|
||||
var linksArray = new BsonArray((canonical.Links.IsDefaultOrEmpty ? ImmutableArray<RawLink>.Empty : canonical.Links)
|
||||
.Select(link => new BsonDocument
|
||||
{
|
||||
{ "scheme", link.Scheme },
|
||||
{ "value", link.Value }
|
||||
}));
|
||||
|
||||
var update = Builders<BsonDocument>.Update
|
||||
.Set("advisory_key", canonical.AdvisoryKey)
|
||||
.Set("links", linksArray);
|
||||
|
||||
await collection.UpdateOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", idValue),
|
||||
update,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RawSourceMetadata ParseSource(BsonDocument source)
|
||||
{
|
||||
return new RawSourceMetadata(
|
||||
GetRequiredString(source, "vendor"),
|
||||
GetOptionalString(source, "connector") ?? string.Empty,
|
||||
GetOptionalString(source, "version") ?? "unknown",
|
||||
GetOptionalString(source, "stream"));
|
||||
}
|
||||
|
||||
private static RawUpstreamMetadata ParseUpstream(BsonDocument upstream)
|
||||
{
|
||||
var provenance = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
|
||||
if (upstream.TryGetValue("provenance", out var provenanceValue) && provenanceValue.IsBsonDocument)
|
||||
{
|
||||
foreach (var element in provenanceValue.AsBsonDocument)
|
||||
{
|
||||
provenance[element.Name] = BsonValueToString(element.Value);
|
||||
}
|
||||
}
|
||||
|
||||
var signature = upstream.TryGetValue("signature", out var signatureValue) && signatureValue.IsBsonDocument
|
||||
? signatureValue.AsBsonDocument
|
||||
: new BsonDocument();
|
||||
|
||||
var signatureMetadata = new RawSignatureMetadata(
|
||||
signature.GetValue("present", BsonBoolean.False).AsBoolean,
|
||||
signature.TryGetValue("format", out var format) && !format.IsBsonNull ? format.AsString : null,
|
||||
signature.TryGetValue("key_id", out var keyId) && !keyId.IsBsonNull ? keyId.AsString : null,
|
||||
signature.TryGetValue("sig", out var sig) && !sig.IsBsonNull ? sig.AsString : null,
|
||||
signature.TryGetValue("certificate", out var certificate) && !certificate.IsBsonNull ? certificate.AsString : null,
|
||||
signature.TryGetValue("digest", out var digest) && !digest.IsBsonNull ? digest.AsString : null);
|
||||
|
||||
return new RawUpstreamMetadata(
|
||||
GetRequiredString(upstream, "upstream_id"),
|
||||
upstream.TryGetValue("document_version", out var version) && !version.IsBsonNull ? version.AsString : null,
|
||||
GetDateTimeOffset(upstream, "retrieved_at", DateTimeOffset.UtcNow),
|
||||
GetRequiredString(upstream, "content_hash"),
|
||||
signatureMetadata,
|
||||
provenance.ToImmutable());
|
||||
}
|
||||
|
||||
private static RawIdentifiers ParseIdentifiers(BsonDocument identifiers)
|
||||
{
|
||||
var aliases = identifiers.TryGetValue("aliases", out var aliasesValue) && aliasesValue.IsBsonArray
|
||||
? aliasesValue.AsBsonArray.Select(BsonValueToString).ToImmutableArray()
|
||||
: ImmutableArray<string>.Empty;
|
||||
|
||||
return new RawIdentifiers(
|
||||
aliases,
|
||||
GetRequiredString(identifiers, "primary"));
|
||||
}
|
||||
|
||||
private static string GetRequiredString(BsonDocument document, string name)
|
||||
{
|
||||
if (!document.TryGetValue(name, out var value) || value.IsBsonNull)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value.IsString ? value.AsString : value.ToString();
|
||||
}
|
||||
|
||||
private static string? GetOptionalString(BsonDocument document, string name)
|
||||
{
|
||||
if (!document.TryGetValue(name, out var value) || value.IsBsonNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.IsString ? value.AsString : value.ToString();
|
||||
}
|
||||
|
||||
private static string BsonValueToString(BsonValue value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
BsonString s => s.AsString,
|
||||
BsonBoolean b => b.AsBoolean.ToString(),
|
||||
BsonDateTime dateTime => dateTime.ToUniversalTime().ToString("O"),
|
||||
BsonInt32 i => i.AsInt32.ToString(CultureInfo.InvariantCulture),
|
||||
BsonInt64 l => l.AsInt64.ToString(CultureInfo.InvariantCulture),
|
||||
BsonDouble d => d.AsDouble.ToString(CultureInfo.InvariantCulture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static DateTimeOffset GetDateTimeOffset(BsonDocument document, string name, DateTimeOffset defaultValue)
|
||||
{
|
||||
if (!document.TryGetValue(name, out var value) || value.IsBsonNull)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return value.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
@@ -223,12 +223,69 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
? EncodeCursor(records[^1].IngestedAt.UtcDateTime, records[^1].Id)
|
||||
: null;
|
||||
|
||||
return new AdvisoryRawQueryResult(records, nextCursor, hasMore);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
|
||||
string tenant,
|
||||
DateTimeOffset since,
|
||||
return new AdvisoryRawQueryResult(records, nextCursor, hasMore);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryRawRecord>> FindByAdvisoryKeyAsync(
|
||||
string tenant,
|
||||
IReadOnlyCollection<string> searchValues,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenant);
|
||||
if (searchValues is null || searchValues.Count == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryRawRecord>();
|
||||
}
|
||||
|
||||
var normalizedValues = searchValues
|
||||
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(static value => value.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (normalizedValues.Length == 0)
|
||||
{
|
||||
return Array.Empty<AdvisoryRawRecord>();
|
||||
}
|
||||
|
||||
var filter = Builders<BsonDocument>.Filter.Eq("tenant", tenant)
|
||||
& Builders<BsonDocument>.Filter.Or(
|
||||
Builders<BsonDocument>.Filter.In("advisory_key", normalizedValues),
|
||||
Builders<BsonDocument>.Filter.ElemMatch(
|
||||
"links",
|
||||
Builders<BsonDocument>.Filter.In("value", normalizedValues)));
|
||||
|
||||
if (sourceVendors is { Count: > 0 })
|
||||
{
|
||||
var vendorValues = sourceVendors
|
||||
.Where(static vendor => !string.IsNullOrWhiteSpace(vendor))
|
||||
.Select(static vendor => vendor.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (vendorValues.Length > 0)
|
||||
{
|
||||
filter &= Builders<BsonDocument>.Filter.In("source.vendor", vendorValues);
|
||||
}
|
||||
}
|
||||
|
||||
var sort = Builders<BsonDocument>.Sort
|
||||
.Descending("created_at")
|
||||
.Descending("_id");
|
||||
|
||||
var documents = await _collection
|
||||
.Find(filter)
|
||||
.Sort(sort)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return documents.Select(MapToRecord).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AdvisoryRawRecord>> ListForVerificationAsync(
|
||||
string tenant,
|
||||
DateTimeOffset since,
|
||||
DateTimeOffset until,
|
||||
IReadOnlyCollection<string> sourceVendors,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -368,29 +425,39 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
}
|
||||
}
|
||||
|
||||
var linkset = new BsonDocument
|
||||
{
|
||||
{ "aliases", new BsonArray(document.Linkset.Aliases) },
|
||||
{ "purls", new BsonArray(document.Linkset.PackageUrls) },
|
||||
{ "cpes", new BsonArray(document.Linkset.Cpes) },
|
||||
{ "references", references },
|
||||
{ "reconciled_from", new BsonArray(document.Linkset.ReconciledFrom) },
|
||||
{ "notes", notes }
|
||||
};
|
||||
|
||||
var bson = new BsonDocument
|
||||
{
|
||||
{ "_id", id },
|
||||
{ "tenant", document.Tenant },
|
||||
{ "source", source },
|
||||
{ "upstream", upstream },
|
||||
{ "content", content },
|
||||
{ "identifiers", identifiers },
|
||||
{ "linkset", linkset },
|
||||
{ "supersedes", supersedesValue is null ? BsonNull.Value : supersedesValue },
|
||||
{ "created_at", document.Upstream.RetrievedAt.UtcDateTime },
|
||||
{ "ingested_at", now }
|
||||
};
|
||||
var linkset = new BsonDocument
|
||||
{
|
||||
{ "aliases", new BsonArray(document.Linkset.Aliases) },
|
||||
{ "purls", new BsonArray(document.Linkset.PackageUrls) },
|
||||
{ "cpes", new BsonArray(document.Linkset.Cpes) },
|
||||
{ "references", references },
|
||||
{ "reconciled_from", new BsonArray(document.Linkset.ReconciledFrom) },
|
||||
{ "notes", notes }
|
||||
};
|
||||
|
||||
var linksArray = new BsonArray(
|
||||
(document.Links.IsDefaultOrEmpty ? ImmutableArray<RawLink>.Empty : document.Links)
|
||||
.Select(link => new BsonDocument
|
||||
{
|
||||
{ "scheme", link.Scheme },
|
||||
{ "value", link.Value }
|
||||
}));
|
||||
|
||||
var bson = new BsonDocument
|
||||
{
|
||||
{ "_id", id },
|
||||
{ "tenant", document.Tenant },
|
||||
{ "source", source },
|
||||
{ "upstream", upstream },
|
||||
{ "content", content },
|
||||
{ "identifiers", identifiers },
|
||||
{ "linkset", linkset },
|
||||
{ "advisory_key", document.AdvisoryKey },
|
||||
{ "links", linksArray },
|
||||
{ "supersedes", supersedesValue is null ? BsonNull.Value : supersedesValue },
|
||||
{ "created_at", document.Upstream.RetrievedAt.UtcDateTime },
|
||||
{ "ingested_at", now }
|
||||
};
|
||||
|
||||
return bson;
|
||||
}
|
||||
@@ -402,17 +469,53 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
var upstream = MapUpstream(document["upstream"].AsBsonDocument);
|
||||
var content = MapContent(document["content"].AsBsonDocument);
|
||||
var identifiers = MapIdentifiers(document["identifiers"].AsBsonDocument);
|
||||
var linkset = MapLinkset(document["linkset"].AsBsonDocument);
|
||||
var supersedes = document.GetValue("supersedes", BsonNull.Value);
|
||||
|
||||
var rawDocument = new AdvisoryRawDocument(
|
||||
tenant,
|
||||
source,
|
||||
upstream,
|
||||
content,
|
||||
identifiers,
|
||||
linkset,
|
||||
supersedes.IsBsonNull ? null : supersedes.AsString);
|
||||
var linkset = MapLinkset(document["linkset"].AsBsonDocument);
|
||||
var supersedes = document.GetValue("supersedes", BsonNull.Value);
|
||||
|
||||
var advisoryKey = document.TryGetValue("advisory_key", out var advisoryKeyValue) && advisoryKeyValue.IsString
|
||||
? advisoryKeyValue.AsString
|
||||
: string.Empty;
|
||||
|
||||
var links = MapLinks(document);
|
||||
AdvisoryCanonicalizationResult? canonical = null;
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey) || links.IsDefaultOrEmpty)
|
||||
{
|
||||
canonical = AdvisoryCanonicalizer.Canonicalize(identifiers, source, upstream);
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
advisoryKey = canonical.AdvisoryKey;
|
||||
}
|
||||
|
||||
if (links.IsDefaultOrEmpty)
|
||||
{
|
||||
links = canonical.Links.IsDefault ? ImmutableArray<RawLink>.Empty : canonical.Links;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(advisoryKey))
|
||||
{
|
||||
canonical ??= AdvisoryCanonicalizer.Canonicalize(identifiers, source, upstream);
|
||||
advisoryKey = canonical.AdvisoryKey;
|
||||
}
|
||||
|
||||
var normalizedLinks = links.IsDefaultOrEmpty
|
||||
? (canonical?.Links ?? ImmutableArray<RawLink>.Empty)
|
||||
: links;
|
||||
if (normalizedLinks.IsDefault)
|
||||
{
|
||||
normalizedLinks = ImmutableArray<RawLink>.Empty;
|
||||
}
|
||||
|
||||
var rawDocument = new AdvisoryRawDocument(
|
||||
tenant,
|
||||
source,
|
||||
upstream,
|
||||
content,
|
||||
identifiers,
|
||||
linkset,
|
||||
advisoryKey,
|
||||
normalizedLinks,
|
||||
supersedes.IsBsonNull ? null : supersedes.AsString);
|
||||
|
||||
var ingestedAt = GetDateTimeOffset(document, "ingested_at", rawDocument.Upstream.RetrievedAt);
|
||||
var createdAt = GetDateTimeOffset(document, "created_at", rawDocument.Upstream.RetrievedAt);
|
||||
@@ -499,7 +602,7 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
GetRequiredString(identifiers, "primary"));
|
||||
}
|
||||
|
||||
private static RawLinkset MapLinkset(BsonDocument linkset)
|
||||
private static RawLinkset MapLinkset(BsonDocument linkset)
|
||||
{
|
||||
var aliases = linkset.TryGetValue("aliases", out var aliasesValue) && aliasesValue.IsBsonArray
|
||||
? aliasesValue.AsBsonArray.Select(BsonValueToString).ToImmutableArray()
|
||||
@@ -549,7 +652,36 @@ internal sealed class MongoAdvisoryRawRepository : IAdvisoryRawRepository
|
||||
ReconciledFrom = reconciledFrom,
|
||||
Notes = notesBuilder.ToImmutable()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static ImmutableArray<RawLink> MapLinks(BsonDocument document)
|
||||
{
|
||||
if (!document.TryGetValue("links", out var linksValue) || !linksValue.IsBsonArray)
|
||||
{
|
||||
return ImmutableArray<RawLink>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<RawLink>();
|
||||
foreach (var element in linksValue.AsBsonArray)
|
||||
{
|
||||
if (!element.IsBsonDocument)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var linkDoc = element.AsBsonDocument;
|
||||
var scheme = GetOptionalString(linkDoc, "scheme") ?? string.Empty;
|
||||
var value = GetOptionalString(linkDoc, "value") ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(new RawLink(scheme, value));
|
||||
}
|
||||
|
||||
return builder.Count == 0 ? ImmutableArray<RawLink>.Empty : builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static DateTimeOffset GetDateTimeOffset(BsonDocument document, string field, DateTimeOffset fallback)
|
||||
{
|
||||
|
||||
@@ -108,6 +108,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IMongoMigration, EnsureGridFsExpiryIndexesMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisoryRawIdempotencyIndexMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisorySupersedesBackfillMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisoryCanonicalKeyBackfillMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisoryRawValidatorMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisoryObservationsRawLinksetMigration>();
|
||||
services.AddSingleton<IMongoMigration, EnsureAdvisoryEventCollectionsMigration>();
|
||||
|
||||
Reference in New Issue
Block a user