Implement MongoDB-based storage for Pack Run approval, artifact, log, and state management
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 MongoPackRunApprovalStore for managing approval states with MongoDB. - Introduced MongoPackRunArtifactUploader for uploading and storing artifacts. - Created MongoPackRunLogStore to handle logging of pack run events. - Developed MongoPackRunStateStore for persisting and retrieving pack run states. - Implemented unit tests for MongoDB stores to ensure correct functionality. - Added MongoTaskRunnerTestContext for setting up MongoDB test environment. - Enhanced PackRunStateFactory to correctly initialize state with gate reasons.
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
public sealed record AdvisoryChunkCollectionResponse(
|
||||
string AdvisoryKey,
|
||||
int Total,
|
||||
bool Truncated,
|
||||
IReadOnlyList<AdvisoryChunkItemResponse> Chunks,
|
||||
IReadOnlyList<AdvisoryChunkSourceResponse> Sources);
|
||||
|
||||
public sealed record AdvisoryChunkItemResponse(
|
||||
string DocumentId,
|
||||
string ChunkId,
|
||||
string Section,
|
||||
string ParagraphId,
|
||||
string Text,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
|
||||
public sealed record AdvisoryChunkSourceResponse(
|
||||
string ObservationId,
|
||||
string DocumentId,
|
||||
string Format,
|
||||
string Vendor,
|
||||
string ContentHash,
|
||||
DateTimeOffset CreatedAt);
|
||||
@@ -111,14 +111,17 @@ internal static class JobRegistrationExtensions
|
||||
|
||||
private static void ConfigureMergeJob(JobSchedulerOptions options, IConfiguration configuration)
|
||||
{
|
||||
var noMergeEnabled = configuration.GetValue<bool?>("concelier:features:noMergeEnabled") ?? true;
|
||||
var noMergeEnabled = configuration.GetValue<bool?>("concelier:features:noMergeEnabled")
|
||||
?? configuration.GetValue<bool?>("features:noMergeEnabled")
|
||||
?? true;
|
||||
if (noMergeEnabled)
|
||||
{
|
||||
options.Definitions.Remove(MergeReconcileBuiltInJob.Kind);
|
||||
return;
|
||||
}
|
||||
|
||||
var allowlist = configuration.GetSection("concelier:jobs:merge:allowlist").Get<string[]>();
|
||||
var allowlist = configuration.GetSection("concelier:jobs:merge:allowlist").Get<string[]>()
|
||||
?? configuration.GetSection("jobs:merge:allowlist").Get<string[]>();
|
||||
if (allowlist is { Length: > 0 })
|
||||
{
|
||||
var allowlistSet = new HashSet<string>(allowlist, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -17,6 +17,8 @@ public sealed class ConcelierOptions
|
||||
public MirrorOptions Mirror { get; set; } = new();
|
||||
|
||||
public FeaturesOptions Features { get; set; } = new();
|
||||
|
||||
public AdvisoryChunkOptions AdvisoryChunks { get; set; } = new();
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
@@ -81,6 +83,8 @@ public sealed class ConcelierOptions
|
||||
|
||||
public IList<string> RequiredScopes { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> RequiredTenants { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> BypassNetworks { get; set; } = new List<string>();
|
||||
|
||||
public string? ClientId { get; set; }
|
||||
@@ -146,4 +150,19 @@ public sealed class ConcelierOptions
|
||||
|
||||
public IList<string> MergeJobAllowlist { get; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class AdvisoryChunkOptions
|
||||
{
|
||||
public int DefaultChunkLimit { get; set; } = 200;
|
||||
|
||||
public int MaxChunkLimit { get; set; } = 400;
|
||||
|
||||
public int DefaultObservationLimit { get; set; } = 24;
|
||||
|
||||
public int MaxObservationLimit { get; set; } = 48;
|
||||
|
||||
public int DefaultMinimumLength { get; set; } = 64;
|
||||
|
||||
public int MaxMinimumLength { get; set; } = 512;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,14 @@ public static class ConcelierOptionsValidator
|
||||
|
||||
options.Authority ??= new ConcelierOptions.AuthorityOptions();
|
||||
options.Authority.Resilience ??= new ConcelierOptions.AuthorityOptions.ResilienceOptions();
|
||||
options.Authority.RequiredTenants ??= new List<string>();
|
||||
NormalizeList(options.Authority.Audiences, toLower: false);
|
||||
NormalizeList(options.Authority.RequiredScopes, toLower: true);
|
||||
NormalizeList(options.Authority.BypassNetworks, toLower: false);
|
||||
NormalizeList(options.Authority.ClientScopes, toLower: true);
|
||||
NormalizeList(options.Authority.RequiredTenants, toLower: true);
|
||||
ValidateResilience(options.Authority.Resilience);
|
||||
ValidateTenantAllowlist(options.Authority.RequiredTenants);
|
||||
|
||||
if (options.Authority.RequiredScopes.Count == 0)
|
||||
{
|
||||
@@ -133,6 +136,9 @@ public static class ConcelierOptionsValidator
|
||||
|
||||
options.Mirror ??= new ConcelierOptions.MirrorOptions();
|
||||
ValidateMirror(options.Mirror);
|
||||
|
||||
options.AdvisoryChunks ??= new ConcelierOptions.AdvisoryChunkOptions();
|
||||
ValidateAdvisoryChunks(options.AdvisoryChunks);
|
||||
}
|
||||
|
||||
private static void NormalizeList(IList<string> values, bool toLower)
|
||||
@@ -190,6 +196,32 @@ public static class ConcelierOptionsValidator
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTenantAllowlist(IList<string> tenants)
|
||||
{
|
||||
if (tenants is null || tenants.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tenant) || tenant.Length > 64)
|
||||
{
|
||||
throw new InvalidOperationException("Authority requiredTenants entries must be between 1 and 64 characters.");
|
||||
}
|
||||
|
||||
foreach (var ch in tenant)
|
||||
{
|
||||
var isAlpha = ch is >= 'a' and <= 'z';
|
||||
var isDigit = ch is >= '0' and <= '9';
|
||||
if (!isAlpha && !isDigit && ch != '-')
|
||||
{
|
||||
throw new InvalidOperationException($"Authority requiredTenants entry '{tenant}' contains invalid character '{ch}'. Use lowercase letters, digits, or '-'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateMirror(ConcelierOptions.MirrorOptions mirror)
|
||||
{
|
||||
if (mirror.MaxIndexRequestsPerHour < 0)
|
||||
@@ -242,4 +274,37 @@ public static class ConcelierOptionsValidator
|
||||
throw new InvalidOperationException("Mirror distribution requires at least one domain when enabled.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAdvisoryChunks(ConcelierOptions.AdvisoryChunkOptions chunks)
|
||||
{
|
||||
if (chunks.DefaultChunkLimit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk defaultChunkLimit must be greater than zero.");
|
||||
}
|
||||
|
||||
if (chunks.MaxChunkLimit < chunks.DefaultChunkLimit)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk maxChunkLimit must be greater than or equal to defaultChunkLimit.");
|
||||
}
|
||||
|
||||
if (chunks.DefaultObservationLimit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk defaultObservationLimit must be greater than zero.");
|
||||
}
|
||||
|
||||
if (chunks.MaxObservationLimit < chunks.DefaultObservationLimit)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk maxObservationLimit must be greater than or equal to defaultObservationLimit.");
|
||||
}
|
||||
|
||||
if (chunks.DefaultMinimumLength <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk defaultMinimumLength must be greater than zero.");
|
||||
}
|
||||
|
||||
if (chunks.MaxMinimumLength < chunks.DefaultMinimumLength)
|
||||
{
|
||||
throw new InvalidOperationException("Advisory chunk maxMinimumLength must be greater than or equal to defaultMinimumLength.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,12 @@ var resolvedConcelierOptions = app.Services.GetRequiredService<IOptions<Concelie
|
||||
var resolvedAuthority = resolvedConcelierOptions.Authority ?? new ConcelierOptions.AuthorityOptions();
|
||||
authorityConfigured = resolvedAuthority.Enabled;
|
||||
var enforceAuthority = resolvedAuthority.Enabled && !resolvedAuthority.AllowAnonymousFallback;
|
||||
var requiredTenants = (resolvedAuthority.RequiredTenants ?? Array.Empty<string>())
|
||||
.Select(static tenant => tenant?.Trim().ToLowerInvariant())
|
||||
.Where(static tenant => !string.IsNullOrWhiteSpace(tenant))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToImmutableHashSet(StringComparer.Ordinal);
|
||||
var enforceTenantAllowlist = !requiredTenants.IsEmpty;
|
||||
|
||||
if (resolvedAuthority.Enabled && resolvedAuthority.AllowAnonymousFallback)
|
||||
{
|
||||
@@ -358,11 +364,14 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async (
|
||||
AdvisoryIngestRequest request,
|
||||
[FromServices] IAdvisoryRawService rawService,
|
||||
[FromServices] TimeProvider timeProvider,
|
||||
[FromServices] ILogger<Program> logger,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
ApplyNoCache(context.Response);
|
||||
|
||||
if (request is null || request.Source is null || request.Upstream is null || request.Content is null || request.Identifiers is null)
|
||||
var ingestRequest = request;
|
||||
|
||||
if (ingestRequest is null || ingestRequest.Source is null || ingestRequest.Upstream is null || ingestRequest.Content is null || ingestRequest.Identifiers is null)
|
||||
{
|
||||
return Problem(context, "Invalid request", StatusCodes.Status400BadRequest, ProblemTypes.Validation, "source, upstream, content, and identifiers sections are required.");
|
||||
}
|
||||
@@ -381,7 +390,14 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async (
|
||||
AdvisoryRawDocument document;
|
||||
try
|
||||
{
|
||||
document = AdvisoryRawRequestMapper.Map(request, tenant, timeProvider);
|
||||
logger.LogWarning(
|
||||
"Binding advisory ingest request hash={Hash}",
|
||||
ingestRequest.Upstream.ContentHash ?? "(null)");
|
||||
|
||||
document = AdvisoryRawRequestMapper.Map(ingestRequest, tenant, timeProvider);
|
||||
logger.LogWarning(
|
||||
"Mapped advisory_raw document hash={Hash}",
|
||||
string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
@@ -418,6 +434,15 @@ var advisoryIngestEndpoint = app.MapPost("/ingest/advisory", async (
|
||||
}
|
||||
catch (ConcelierAocGuardException guardException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
guardException,
|
||||
"AOC guard rejected advisory ingest tenant={Tenant} upstream={UpstreamId} requestHash={RequestHash} documentHash={DocumentHash} codes={Codes}",
|
||||
tenant,
|
||||
document.Upstream.UpstreamId,
|
||||
request!.Upstream?.ContentHash ?? "(null)",
|
||||
string.IsNullOrWhiteSpace(document.Upstream.ContentHash) ? "(empty)" : document.Upstream.ContentHash,
|
||||
string.Join(',', guardException.Violations.Select(static violation => violation.ErrorCode)));
|
||||
|
||||
IngestionMetrics.ViolationCounter.Add(1, new[]
|
||||
{
|
||||
new KeyValuePair<string, object?>("tenant", tenant),
|
||||
@@ -945,6 +970,11 @@ IResult? EnsureTenantAuthorized(HttpContext context, string tenant)
|
||||
return null;
|
||||
}
|
||||
|
||||
if (enforceTenantAllowlist && !requiredTenants.Contains(tenant))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var principal = context.User;
|
||||
|
||||
if (enforceAuthority && (principal?.Identity?.IsAuthenticated != true))
|
||||
@@ -965,6 +995,11 @@ IResult? EnsureTenantAuthorized(HttpContext context, string tenant)
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (enforceTenantAllowlist && !requiredTenants.Contains(normalizedClaim))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Concelier.Models.Observations;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Services;
|
||||
|
||||
internal sealed record AdvisoryChunkBuildOptions(
|
||||
string AdvisoryKey,
|
||||
int ChunkLimit,
|
||||
int ObservationLimit,
|
||||
ImmutableHashSet<string> SectionFilter,
|
||||
ImmutableHashSet<string> FormatFilter,
|
||||
int MinimumLength);
|
||||
|
||||
internal sealed class AdvisoryChunkBuilder
|
||||
{
|
||||
private const int DefaultMinLength = 40;
|
||||
|
||||
public AdvisoryChunkCollectionResponse Build(
|
||||
AdvisoryChunkBuildOptions options,
|
||||
IReadOnlyList<AdvisoryObservation> observations)
|
||||
{
|
||||
var chunks = new List<AdvisoryChunkItemResponse>(Math.Min(options.ChunkLimit, 256));
|
||||
var sources = new List<AdvisoryChunkSourceResponse>();
|
||||
var total = 0;
|
||||
var truncated = false;
|
||||
|
||||
foreach (var observation in observations
|
||||
.OrderByDescending(o => o.CreatedAt))
|
||||
{
|
||||
if (sources.Count >= options.ObservationLimit)
|
||||
{
|
||||
truncated = truncated || chunks.Count == options.ChunkLimit;
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.FormatFilter.Count > 0 &&
|
||||
!options.FormatFilter.Contains(observation.Content.Format))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var documentId = DetermineDocumentId(observation);
|
||||
sources.Add(new AdvisoryChunkSourceResponse(
|
||||
observation.ObservationId,
|
||||
documentId,
|
||||
observation.Content.Format,
|
||||
observation.Source.Vendor,
|
||||
observation.Upstream.ContentHash,
|
||||
observation.CreatedAt));
|
||||
|
||||
foreach (var chunk in ExtractChunks(observation, documentId, options))
|
||||
{
|
||||
total++;
|
||||
if (chunks.Count < options.ChunkLimit)
|
||||
{
|
||||
chunks.Add(chunk);
|
||||
}
|
||||
else
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!truncated)
|
||||
{
|
||||
total = chunks.Count;
|
||||
}
|
||||
|
||||
return new AdvisoryChunkCollectionResponse(
|
||||
options.AdvisoryKey,
|
||||
total,
|
||||
truncated,
|
||||
chunks,
|
||||
sources);
|
||||
}
|
||||
|
||||
private static string DetermineDocumentId(AdvisoryObservation observation)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(observation.Upstream.UpstreamId))
|
||||
{
|
||||
return observation.Upstream.UpstreamId;
|
||||
}
|
||||
|
||||
return observation.ObservationId;
|
||||
}
|
||||
|
||||
private static IEnumerable<AdvisoryChunkItemResponse> ExtractChunks(
|
||||
AdvisoryObservation observation,
|
||||
string documentId,
|
||||
AdvisoryChunkBuildOptions options)
|
||||
{
|
||||
var root = observation.Content.Raw;
|
||||
if (root is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var stack = new Stack<(JsonNode Node, string Path, string Section)>();
|
||||
stack.Push((root, string.Empty, string.Empty));
|
||||
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
var (node, path, section) = stack.Pop();
|
||||
if (node is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (node)
|
||||
{
|
||||
case JsonValue value when TryNormalize(value, out var text):
|
||||
if (text.Length < Math.Max(options.MinimumLength, DefaultMinLength))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ContainsLetter(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolvedSection = string.IsNullOrEmpty(section) ? documentId : section;
|
||||
if (options.SectionFilter.Count > 0 && !options.SectionFilter.Contains(resolvedSection))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var paragraphId = string.IsNullOrEmpty(path) ? resolvedSection : path;
|
||||
var chunkId = CreateChunkId(documentId, paragraphId);
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["path"] = paragraphId,
|
||||
["section"] = resolvedSection,
|
||||
["format"] = observation.Content.Format
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(observation.Content.SpecVersion))
|
||||
{
|
||||
metadata["specVersion"] = observation.Content.SpecVersion!;
|
||||
}
|
||||
|
||||
yield return new AdvisoryChunkItemResponse(
|
||||
documentId,
|
||||
chunkId,
|
||||
resolvedSection,
|
||||
paragraphId,
|
||||
text,
|
||||
metadata);
|
||||
break;
|
||||
|
||||
case JsonObject obj:
|
||||
foreach (var property in obj.Reverse())
|
||||
{
|
||||
var childSection = string.IsNullOrEmpty(section) ? property.Key : section;
|
||||
var childPath = AppendPath(path, property.Key);
|
||||
if (property.Value is { } childNode)
|
||||
{
|
||||
stack.Push((childNode, childPath, childSection));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
for (var index = array.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var childPath = AppendIndex(path, index);
|
||||
if (array[index] is { } childNode)
|
||||
{
|
||||
stack.Push((childNode, childPath, section));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalize(JsonValue value, out string normalized)
|
||||
{
|
||||
normalized = string.Empty;
|
||||
if (!value.TryGetValue(out string? text) || text is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var span = text.AsSpan();
|
||||
var builder = new StringBuilder(span.Length);
|
||||
var previousWhitespace = false;
|
||||
|
||||
foreach (var ch in span)
|
||||
{
|
||||
if (char.IsControl(ch) && !char.IsWhiteSpace(ch))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (previousWhitespace)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(' ');
|
||||
previousWhitespace = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
previousWhitespace = false;
|
||||
}
|
||||
}
|
||||
|
||||
normalized = builder.ToString().Trim();
|
||||
return normalized.Length > 0;
|
||||
}
|
||||
|
||||
private static bool ContainsLetter(string text)
|
||||
=> text.Any(static ch => char.IsLetter(ch));
|
||||
|
||||
private static string AppendPath(string path, string? segment)
|
||||
{
|
||||
var safeSegment = segment ?? string.Empty;
|
||||
return string.IsNullOrEmpty(path) ? safeSegment : string.Concat(path, '.', safeSegment);
|
||||
}
|
||||
|
||||
private static string AppendIndex(string path, int index)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return $"[{index}]";
|
||||
}
|
||||
|
||||
return string.Concat(path, '[', index.ToString(CultureInfo.InvariantCulture), ']');
|
||||
}
|
||||
|
||||
private static string CreateChunkId(string documentId, string paragraphId)
|
||||
{
|
||||
var input = string.Concat(documentId, '|', paragraphId);
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
|
||||
return string.Concat(documentId, ':', Convert.ToHexString(hash.AsSpan(0, 8)));
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
|
||||
| ID | Status | Owner(s) | Depends on | Notes |
|
||||
|----|--------|----------|------------|-------|
|
||||
| CONCELIER-AIAI-31-001 `Paragraph anchors` | TODO | Concelier WebService Guild | CONCELIER-VULN-29-001 | Expose advisory chunk API returning paragraph anchors, section metadata, and token-safe text for Advisory AI retrieval. |
|
||||
| CONCELIER-AIAI-31-001 `Paragraph anchors` | DONE | Concelier WebService Guild | CONCELIER-VULN-29-001 | Expose advisory chunk API returning paragraph anchors, section metadata, and token-safe text for Advisory AI retrieval. See docs/updates/2025-11-07-concelier-advisory-chunks.md. |
|
||||
| CONCELIER-AIAI-31-002 `Structured fields` | TODO | Concelier WebService Guild | CONCELIER-AIAI-31-001 | Ensure observation APIs expose upstream workaround/fix/CVSS fields with provenance; add caching for summary queries. |
|
||||
| CONCELIER-AIAI-31-003 `Advisory AI telemetry` | TODO | Concelier WebService Guild, Observability Guild | CONCELIER-AIAI-31-001 | Emit metrics/logs for chunk requests, cache hits, and guardrail blocks triggered by advisory payloads. |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| CONCELIER-CORE-AOC-19-004 `Remove ingestion normalization` | DONE (2025-11-06) | Concelier Core Guild | CONCELIER-CORE-AOC-19-002, POLICY-AOC-19-003 | Strip normalization/dedup/severity logic from ingestion pipelines, delegate derived computations to Policy Engine, and update exporters/tests to consume raw documents only.<br>2025-10-29 19:05Z: Audit completed for `AdvisoryRawService`/Mongo repo to confirm alias order/dedup removal persists; identified remaining normalization in observation/linkset factory that will be revised to surface raw duplicates for Policy ingestion. Change sketch + regression matrix drafted under `docs/dev/aoc-normalization-removal-notes.md` (pending commit).<br>2025-10-31 20:45Z: Added raw linkset projection to observations/storage, exposing canonical+raw views, refreshed fixtures/tests, and documented behaviour in models/doc factory.<br>2025-10-31 21:10Z: Coordinated with Policy Engine (POLICY-ENGINE-20-003) on adoption timeline; backfill + consumer readiness tracked in `docs/dev/raw-linkset-backfill-plan.md`.<br>2025-11-05 14:25Z: Resuming to document merge-dependent normalization paths and prepare implementation notes for `noMergeEnabled` gating before code changes land.<br>2025-11-05 19:20Z: Observation factory/linkset now preserve upstream ordering + duplicates; canonicalisation responsibility shifts to downstream consumers with refreshed unit coverage.<br>2025-11-06 16:10Z: Updated AOC reference/backfill docs with raw vs canonical guidance and cross-linked analyzer guardrails.<br>2025-11-06 23:40Z: Final pass preserves raw alias casing/whitespace end-to-end; query filters now compare case-insensitively, exporter fixtures refreshed, and docs aligned. Tests: `StellaOps.Concelier.Models/Core/Storage.Mongo.Tests` green on .NET 10 preview. |
|
||||
> Docs alignment (2025-10-26): Architecture overview emphasises policy-only derivation; coordinate with Policy Engine guild for rollout.
|
||||
> 2025-10-29: `AdvisoryRawService` now preserves upstream alias/linkset ordering (trim-only) and updated AOC documentation reflects the behaviour; follow-up to ensure policy consumers handle duplicates remains open.
|
||||
| CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | TODO | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. |
|
||||
| CONCELIER-CORE-AOC-19-013 `Authority tenant scope smoke coverage` | DONE (2025-11-07) | Concelier Core Guild | AUTH-AOC-19-002 | Extend Concelier smoke/e2e fixtures to configure `requiredTenants` and assert cross-tenant rejection with updated Authority tokens. | Coordinate deliverable so Authority docs (`AUTH-AOC-19-003`) can close once tests are in place. |
|
||||
|
||||
## Policy Engine v2
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ public static class MergeServiceCollectionExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
var noMergeEnabled = configuration.GetValue<bool?>("concelier:features:noMergeEnabled") ?? true;
|
||||
var noMergeEnabled = configuration.GetValue<bool?>("concelier:features:noMergeEnabled")
|
||||
?? configuration.GetValue<bool?>("features:noMergeEnabled")
|
||||
?? true;
|
||||
if (noMergeEnabled)
|
||||
{
|
||||
return services;
|
||||
|
||||
@@ -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-06)** – 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.|
|
||||
|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.|
|
||||
> 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.|
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
@@ -22,6 +23,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Mongo2Go;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.IO;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Concelier.Core.Events;
|
||||
using StellaOps.Concelier.Core.Jobs;
|
||||
@@ -29,6 +31,7 @@ using StellaOps.Concelier.Models;
|
||||
using StellaOps.Concelier.Merge.Services;
|
||||
using StellaOps.Concelier.Storage.Mongo;
|
||||
using StellaOps.Concelier.Storage.Mongo.Observations;
|
||||
using StellaOps.Concelier.Core.Raw;
|
||||
using StellaOps.Concelier.WebService.Jobs;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Contracts;
|
||||
@@ -36,6 +39,7 @@ using Xunit.Sdk;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using StellaOps.Concelier.WebService.Diagnostics;
|
||||
@@ -50,9 +54,15 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
private const string TestSigningSecret = "0123456789ABCDEF0123456789ABCDEF";
|
||||
private static readonly SymmetricSecurityKey TestSigningKey = new(Encoding.UTF8.GetBytes(TestSigningSecret));
|
||||
|
||||
private readonly ITestOutputHelper _output;
|
||||
private MongoDbRunner _runner = null!;
|
||||
private ConcelierApplicationFactory _factory = null!;
|
||||
|
||||
public WebServiceEndpointsTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
}
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
_runner = MongoDbRunner.Start(singleNodeReplSet: true);
|
||||
@@ -200,17 +210,123 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.True(response.StatusCode == HttpStatusCode.BadRequest, $"Expected 400 but got {(int)response.StatusCode}: {body}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryChunksEndpoint_ReturnsParagraphAnchors()
|
||||
{
|
||||
var newestRaw = BsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"summary": {
|
||||
"intro": "This is a deterministic summary paragraph describing CVE-2025-0001 with remediation context for Advisory AI consumers."
|
||||
},
|
||||
"details": [
|
||||
"Long-form remediation guidance that exceeds the minimum length threshold and mentions affected packages.",
|
||||
{
|
||||
"body": "Nested context that Advisory AI can cite when rendering downstream explanations."
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
var olderRaw = BsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"summary": {
|
||||
"intro": "Older paragraph that should be visible when no section filter applies."
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var newerCreatedAt = new DateTime(2025, 1, 7, 0, 0, 0, DateTimeKind.Utc);
|
||||
var olderCreatedAt = new DateTime(2025, 1, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
var newerHash = ComputeContentHash(newestRaw);
|
||||
var olderHash = ComputeContentHash(olderRaw);
|
||||
|
||||
var documents = new[]
|
||||
{
|
||||
CreateChunkObservationDocument(
|
||||
id: "tenant-a:chunk:newest",
|
||||
tenant: "tenant-a",
|
||||
createdAt: newerCreatedAt,
|
||||
alias: "cve-2025-0001",
|
||||
rawDocument: newestRaw),
|
||||
CreateChunkObservationDocument(
|
||||
id: "tenant-a:chunk:older",
|
||||
tenant: "tenant-a",
|
||||
createdAt: olderCreatedAt,
|
||||
alias: "cve-2025-0001",
|
||||
rawDocument: olderRaw)
|
||||
};
|
||||
|
||||
await SeedObservationDocumentsAsync(documents);
|
||||
await SeedAdvisoryRawDocumentsAsync(
|
||||
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:newest", newerHash, newestRaw.DeepClone().AsBsonDocument),
|
||||
CreateAdvisoryRawDocument("tenant-a", "nvd", "tenant-a:chunk:older", olderHash, olderRaw.DeepClone().AsBsonDocument));
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/advisories/cve-2025-0001/chunks?tenant=tenant-a§ion=summary&format=csaf");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
|
||||
Assert.Equal("cve-2025-0001", root.GetProperty("advisoryKey").GetString());
|
||||
Assert.Equal(1, root.GetProperty("total").GetInt32());
|
||||
Assert.False(root.GetProperty("truncated").GetBoolean());
|
||||
|
||||
var chunk = Assert.Single(root.GetProperty("chunks").EnumerateArray());
|
||||
Assert.Equal("summary", chunk.GetProperty("section").GetString());
|
||||
Assert.Equal("summary.intro", chunk.GetProperty("paragraphId").GetString());
|
||||
var text = chunk.GetProperty("text").GetString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
Assert.Contains("deterministic summary paragraph", text, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var metadata = chunk.GetProperty("metadata");
|
||||
Assert.Equal("summary.intro", metadata.GetProperty("path").GetString());
|
||||
Assert.Equal("csaf", metadata.GetProperty("format").GetString());
|
||||
|
||||
var sources = root.GetProperty("sources").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, sources.Length);
|
||||
Assert.Equal("tenant-a:chunk:newest", sources[0].GetProperty("observationId").GetString());
|
||||
Assert.Equal("tenant-a:chunk:older", sources[1].GetProperty("observationId").GetString());
|
||||
Assert.All(
|
||||
sources,
|
||||
source => Assert.True(string.Equals("csaf", source.GetProperty("format").GetString(), StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryChunksEndpoint_ReturnsNotFoundWhenAdvisoryMissing()
|
||||
{
|
||||
await SeedObservationDocumentsAsync(BuildSampleObservationDocuments());
|
||||
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/advisories/cve-2099-9999/chunks?tenant=tenant-a");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
var payload = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(payload);
|
||||
var root = document.RootElement;
|
||||
Assert.Equal("https://stellaops.org/problems/not-found", root.GetProperty("type").GetString());
|
||||
Assert.Equal("Advisory not found", root.GetProperty("title").GetString());
|
||||
Assert.Contains("cve-2099-9999", root.GetProperty("detail").GetString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryIngestEndpoint_PersistsDocumentAndSupportsReadback()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-ingest");
|
||||
|
||||
const string upstreamId = "GHSA-INGEST-0001";
|
||||
var ingestRequest = BuildAdvisoryIngestRequest(
|
||||
contentHash: "sha256:abc123",
|
||||
upstreamId: "GHSA-INGEST-0001");
|
||||
contentHash: null,
|
||||
upstreamId: upstreamId);
|
||||
|
||||
var ingestResponse = await client.PostAsJsonAsync("/ingest/advisory", ingestRequest);
|
||||
if (ingestResponse.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
WriteProgramLogs();
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.Created, ingestResponse.StatusCode);
|
||||
|
||||
var ingestPayload = await ingestResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
|
||||
@@ -218,7 +334,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.True(ingestPayload!.Inserted);
|
||||
Assert.False(string.IsNullOrWhiteSpace(ingestPayload.Id));
|
||||
Assert.Equal("tenant-ingest", ingestPayload.Tenant);
|
||||
Assert.Equal("sha256:abc123", ingestPayload.ContentHash);
|
||||
Assert.Equal(ComputeDeterministicContentHash(upstreamId), ingestPayload.ContentHash);
|
||||
Assert.NotNull(ingestResponse.Headers.Location);
|
||||
var locationValue = ingestResponse.Headers.Location!.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(locationValue));
|
||||
@@ -230,8 +346,8 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(ingestPayload.Id, decodedSegment);
|
||||
|
||||
var duplicateResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest(
|
||||
contentHash: "sha256:abc123",
|
||||
upstreamId: "GHSA-INGEST-0001"));
|
||||
contentHash: null,
|
||||
upstreamId: upstreamId));
|
||||
Assert.Equal(HttpStatusCode.OK, duplicateResponse.StatusCode);
|
||||
var duplicatePayload = await duplicateResponse.Content.ReadFromJsonAsync<AdvisoryIngestResponse>();
|
||||
Assert.NotNull(duplicatePayload);
|
||||
@@ -247,7 +363,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.NotNull(record);
|
||||
Assert.Equal(ingestPayload.Id, record!.Id);
|
||||
Assert.Equal("tenant-ingest", record.Tenant);
|
||||
Assert.Equal("sha256:abc123", record.Document.Upstream.ContentHash);
|
||||
Assert.Equal(ComputeDeterministicContentHash(upstreamId), record.Document.Upstream.ContentHash);
|
||||
}
|
||||
|
||||
using (var listRequest = new HttpRequestMessage(HttpMethod.Get, "/advisories/raw?limit=10"))
|
||||
@@ -451,6 +567,54 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
Assert.Equal(HttpStatusCode.Forbidden, crossTenantResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryIngestEndpoint_RejectsTenantOutsideAllowlist()
|
||||
{
|
||||
var environment = new Dictionary<string, string?>
|
||||
{
|
||||
["CONCELIER_AUTHORITY__ENABLED"] = "true",
|
||||
["CONCELIER_AUTHORITY__ALLOWANONYMOUSFALLBACK"] = "false",
|
||||
["CONCELIER_AUTHORITY__ISSUER"] = TestAuthorityIssuer,
|
||||
["CONCELIER_AUTHORITY__REQUIREHTTPSMETADATA"] = "false",
|
||||
["CONCELIER_AUTHORITY__AUDIENCES__0"] = TestAuthorityAudience,
|
||||
["CONCELIER_AUTHORITY__CLIENTID"] = "webservice-tests",
|
||||
["CONCELIER_AUTHORITY__CLIENTSECRET"] = "unused",
|
||||
["CONCELIER_AUTHORITY__REQUIREDTENANTS__0"] = "tenant-auth"
|
||||
};
|
||||
|
||||
using var factory = new ConcelierApplicationFactory(
|
||||
_runner.ConnectionString,
|
||||
authority =>
|
||||
{
|
||||
authority.Enabled = true;
|
||||
authority.AllowAnonymousFallback = false;
|
||||
authority.Issuer = TestAuthorityIssuer;
|
||||
authority.RequireHttpsMetadata = false;
|
||||
authority.Audiences.Clear();
|
||||
authority.Audiences.Add(TestAuthorityAudience);
|
||||
authority.ClientId = "webservice-tests";
|
||||
authority.ClientSecret = "unused";
|
||||
authority.RequiredTenants.Clear();
|
||||
authority.RequiredTenants.Add("tenant-auth");
|
||||
},
|
||||
environment);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
var allowedToken = CreateTestToken("tenant-auth", StellaOpsScopes.AdvisoryIngest);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", allowedToken);
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-auth");
|
||||
|
||||
var allowedResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-1", "GHSA-ALLOW-001"));
|
||||
Assert.Equal(HttpStatusCode.Created, allowedResponse.StatusCode);
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", CreateTestToken("tenant-blocked", StellaOpsScopes.AdvisoryIngest));
|
||||
client.DefaultRequestHeaders.Remove("X-Stella-Tenant");
|
||||
client.DefaultRequestHeaders.Add("X-Stella-Tenant", "tenant-blocked");
|
||||
|
||||
var forbiddenResponse = await client.PostAsJsonAsync("/ingest/advisory", BuildAdvisoryIngestRequest("sha256:allow-2", "GHSA-ALLOW-002"));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbiddenResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdvisoryIngestEndpoint_ReturnsGuardViolationWhenContentHashMissing()
|
||||
{
|
||||
@@ -1244,6 +1408,55 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
};
|
||||
}
|
||||
|
||||
private static AdvisoryObservationDocument CreateChunkObservationDocument(
|
||||
string id,
|
||||
string tenant,
|
||||
DateTime createdAt,
|
||||
string alias,
|
||||
BsonDocument rawDocument)
|
||||
{
|
||||
var document = CreateObservationDocument(
|
||||
id,
|
||||
tenant,
|
||||
createdAt,
|
||||
aliases: new[] { alias });
|
||||
var clone = rawDocument.DeepClone().AsBsonDocument;
|
||||
document.Content.Raw = clone;
|
||||
document.Upstream.ContentHash = ComputeContentHash(clone);
|
||||
return document;
|
||||
}
|
||||
|
||||
private static readonly DateTimeOffset DefaultIngestTimestamp = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static string ComputeContentHash(BsonDocument rawDocument)
|
||||
{
|
||||
using var sha256 = SHA256.Create();
|
||||
var canonical = rawDocument.ToJson(new JsonWriterSettings
|
||||
{
|
||||
OutputMode = JsonOutputMode.RelaxedExtendedJson
|
||||
});
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(canonical));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static string ComputeDeterministicContentHash(string upstreamId)
|
||||
{
|
||||
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DefaultIngestTimestamp:O}""}}");
|
||||
return NormalizeContentHash(null, raw, enforceContentHash: true);
|
||||
}
|
||||
|
||||
private static string NormalizeContentHash(string? value, JsonElement raw, bool enforceContentHash)
|
||||
{
|
||||
if (!enforceContentHash)
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
using var sha256 = SHA256.Create();
|
||||
var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(raw.GetRawText()));
|
||||
return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private sealed record ReplayResponse(
|
||||
string VulnerabilityKey,
|
||||
DateTimeOffset? AsOf,
|
||||
@@ -1690,8 +1903,18 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
return $"advisory_raw:{vendorSegment}:{upstreamSegment}:{hashSegment}";
|
||||
}
|
||||
|
||||
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(string contentHash, string upstreamId)
|
||||
private void WriteProgramLogs()
|
||||
{
|
||||
var entries = _factory.LoggerProvider.Snapshot("StellaOps.Concelier.WebService.Program");
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
_output.WriteLine($"[PROGRAM LOG] {entry.Level}: {entry.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static AdvisoryIngestRequest BuildAdvisoryIngestRequest(string? contentHash, string upstreamId)
|
||||
{
|
||||
var normalizedContentHash = contentHash ?? ComputeDeterministicContentHash(upstreamId);
|
||||
var raw = CreateJsonElement($@"{{""id"":""{upstreamId}"",""modified"":""{DateTime.UtcNow:O}""}}");
|
||||
var references = new[]
|
||||
{
|
||||
@@ -1704,7 +1927,7 @@ public sealed class WebServiceEndpointsTests : IAsyncLifetime
|
||||
upstreamId,
|
||||
"2025-01-01T00:00:00Z",
|
||||
DateTimeOffset.UtcNow,
|
||||
contentHash,
|
||||
normalizedContentHash,
|
||||
new AdvisorySignatureRequest(false, null, null, null, null, null),
|
||||
new Dictionary<string, string> { ["http.method"] = "GET" }),
|
||||
new AdvisoryContentRequest("osv", "1.3.0", raw, null),
|
||||
|
||||
Reference in New Issue
Block a user