up
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# API Changelog
|
||||
|
||||
Generated: 2025-11-19T07:40:32.086Z
|
||||
Generated: 2025-11-24T01:50:48.086Z
|
||||
|
||||
## Additive Operations
|
||||
- GET /export-center/bundles/{bundleId}/manifest
|
||||
|
||||
@@ -1236,8 +1236,78 @@ internal static class CommandFactory
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var explainOptions = CreateAdvisoryOptions();
|
||||
var explain = new Command("explain", "Explain an advisory conflict set with narrative and rationale.");
|
||||
AddAdvisoryOptions(explain, explainOptions);
|
||||
explain.SetAction((parseResult, _) =>
|
||||
{
|
||||
var advisoryKey = parseResult.GetValue(explainOptions.AdvisoryKey) ?? string.Empty;
|
||||
var artifactId = parseResult.GetValue(explainOptions.ArtifactId);
|
||||
var artifactPurl = parseResult.GetValue(explainOptions.ArtifactPurl);
|
||||
var policyVersion = parseResult.GetValue(explainOptions.PolicyVersion);
|
||||
var profile = parseResult.GetValue(explainOptions.Profile) ?? "default";
|
||||
var sections = parseResult.GetValue(explainOptions.Sections) ?? Array.Empty<string>();
|
||||
var forceRefresh = parseResult.GetValue(explainOptions.ForceRefresh);
|
||||
var timeoutSeconds = parseResult.GetValue(explainOptions.TimeoutSeconds) ?? 120;
|
||||
var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(explainOptions.Format));
|
||||
var outputPath = parseResult.GetValue(explainOptions.Output);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAdviseRunAsync(
|
||||
services,
|
||||
AdvisoryAiTaskType.Conflict,
|
||||
advisoryKey,
|
||||
artifactId,
|
||||
artifactPurl,
|
||||
policyVersion,
|
||||
profile,
|
||||
sections,
|
||||
forceRefresh,
|
||||
timeoutSeconds,
|
||||
outputFormat,
|
||||
outputPath,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
var remediateOptions = CreateAdvisoryOptions();
|
||||
var remediate = new Command("remediate", "Generate remediation guidance for an advisory.");
|
||||
AddAdvisoryOptions(remediate, remediateOptions);
|
||||
remediate.SetAction((parseResult, _) =>
|
||||
{
|
||||
var advisoryKey = parseResult.GetValue(remediateOptions.AdvisoryKey) ?? string.Empty;
|
||||
var artifactId = parseResult.GetValue(remediateOptions.ArtifactId);
|
||||
var artifactPurl = parseResult.GetValue(remediateOptions.ArtifactPurl);
|
||||
var policyVersion = parseResult.GetValue(remediateOptions.PolicyVersion);
|
||||
var profile = parseResult.GetValue(remediateOptions.Profile) ?? "default";
|
||||
var sections = parseResult.GetValue(remediateOptions.Sections) ?? Array.Empty<string>();
|
||||
var forceRefresh = parseResult.GetValue(remediateOptions.ForceRefresh);
|
||||
var timeoutSeconds = parseResult.GetValue(remediateOptions.TimeoutSeconds) ?? 120;
|
||||
var outputFormat = ParseAdvisoryOutputFormat(parseResult.GetValue(remediateOptions.Format));
|
||||
var outputPath = parseResult.GetValue(remediateOptions.Output);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleAdviseRunAsync(
|
||||
services,
|
||||
AdvisoryAiTaskType.Remediation,
|
||||
advisoryKey,
|
||||
artifactId,
|
||||
artifactPurl,
|
||||
policyVersion,
|
||||
profile,
|
||||
sections,
|
||||
forceRefresh,
|
||||
timeoutSeconds,
|
||||
outputFormat,
|
||||
outputPath,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
advise.Add(run);
|
||||
advise.Add(summarize);
|
||||
advise.Add(explain);
|
||||
advise.Add(remediate);
|
||||
return advise;
|
||||
}
|
||||
|
||||
|
||||
@@ -553,6 +553,12 @@ internal static class CommandHandlers
|
||||
logger.LogInformation("Advisory output written to {Path}.", fullPath);
|
||||
}
|
||||
|
||||
if (rendered is not null)
|
||||
{
|
||||
// Surface the rendered advisory to the active console so users (and tests) can see it even when also writing to disk.
|
||||
AnsiConsole.Console.WriteLine(rendered);
|
||||
}
|
||||
|
||||
if (output.Guardrail.Blocked)
|
||||
{
|
||||
logger.LogError("Guardrail blocked advisory output (cache key {CacheKey}).", output.CacheKey);
|
||||
@@ -3075,7 +3081,7 @@ internal static class CommandHandlers
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
|
||||
Console.WriteLine(json);
|
||||
AnsiConsole.Console.WriteLine(json);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -6359,9 +6365,9 @@ internal static class CommandHandlers
|
||||
builder.AppendLine($"# Advisory {output.TaskType} ({output.Profile})");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"- Cache Key: `{output.CacheKey}`");
|
||||
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString(\"O\", CultureInfo.InvariantCulture)}");
|
||||
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? \"yes\" : \"no\")}");
|
||||
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? \"yes\" : \"no\")}");
|
||||
builder.AppendLine($"- Generated: {output.GeneratedAtUtc.ToString("O", CultureInfo.InvariantCulture)}");
|
||||
builder.AppendLine($"- Plan From Cache: {(output.PlanFromCache ? "yes" : "no")}");
|
||||
builder.AppendLine($"- Guardrail Blocked: {(output.Guardrail.Blocked ? "yes" : "no")}");
|
||||
builder.AppendLine();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output.Response))
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
| Task ID | State | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SCANNER-CLI-0001` | DONE (2025-11-12) | Ruby verbs now consume the persisted `RubyPackageInventory`, warn when inventories are missing, and docs/tests were refreshed per Sprint 138. |
|
||||
| `CLI-AIAI-31-001` | BLOCKED (2025-11-22) | `stella advise summarize` command implemented; blocked on upstream Scanner analyzers (Node/Java) compile failures preventing CLI test run. |
|
||||
| `CLI-AIAI-31-001` | DONE (2025-11-24) | `stella advise summarize` command implemented; CLI analyzer build & tests now pass locally. |
|
||||
| `CLI-AIAI-31-002` | DONE (2025-11-24) | `stella advise explain` (conflict narrative) command implemented and tested. |
|
||||
| `CLI-AIAI-31-003` | DONE (2025-11-24) | `stella advise remediate` command implemented and tested. |
|
||||
|
||||
@@ -877,6 +877,202 @@ public sealed class CommandHandlersTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForExplain()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var testConsole = new TestConsole();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.Console = testConsole;
|
||||
|
||||
var planResponse = new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = "Conflict",
|
||||
CacheKey = "plan-conflict",
|
||||
PromptTemplate = "prompts/advisory/conflict.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 128,
|
||||
CompletionTokens = 64
|
||||
},
|
||||
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
||||
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var outputResponse = new AdvisoryPipelineOutputModel
|
||||
{
|
||||
CacheKey = planResponse.CacheKey,
|
||||
TaskType = planResponse.TaskType,
|
||||
Profile = "default",
|
||||
Prompt = "Sanitized prompt",
|
||||
Response = "Rendered conflict body.",
|
||||
Citations = new[]
|
||||
{
|
||||
new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-42", ChunkId = "chunk-42" }
|
||||
},
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Guardrail = new AdvisoryOutputGuardrailModel
|
||||
{
|
||||
Blocked = false,
|
||||
SanitizedPrompt = "Sanitized prompt",
|
||||
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
},
|
||||
Provenance = new AdvisoryOutputProvenanceModel
|
||||
{
|
||||
InputDigest = "sha256:conflict-in",
|
||||
OutputHash = "sha256:conflict-out",
|
||||
Signatures = Array.Empty<string>()
|
||||
},
|
||||
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
PlanFromCache = false
|
||||
};
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
AdvisoryPlanResponse = planResponse,
|
||||
AdvisoryOutputResponse = outputResponse
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
var outputPath = Path.GetTempFileName();
|
||||
|
||||
await CommandHandlers.HandleAdviseRunAsync(
|
||||
provider,
|
||||
AdvisoryAiTaskType.Conflict,
|
||||
"ADV-42",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"default",
|
||||
Array.Empty<string>(),
|
||||
forceRefresh: false,
|
||||
timeoutSeconds: 0,
|
||||
outputFormat: AdvisoryOutputFormat.Markdown,
|
||||
outputPath: outputPath,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var markdown = await File.ReadAllTextAsync(outputPath);
|
||||
Assert.Contains("Conflict", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Rendered conflict body", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("doc-42", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("chunk-42", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Contains("Conflict", testConsole.Output, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(AdvisoryAiTaskType.Conflict, backend.AdvisoryPlanRequests.Last().TaskType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnsiConsole.Console = originalConsole;
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_WritesMarkdownWithCitations_ForRemediation()
|
||||
{
|
||||
var originalExit = Environment.ExitCode;
|
||||
var originalConsole = AnsiConsole.Console;
|
||||
var testConsole = new TestConsole();
|
||||
|
||||
try
|
||||
{
|
||||
Environment.ExitCode = 0;
|
||||
AnsiConsole.Console = testConsole;
|
||||
|
||||
var planResponse = new AdvisoryPipelinePlanResponseModel
|
||||
{
|
||||
TaskType = "Remediation",
|
||||
CacheKey = "plan-remediation",
|
||||
PromptTemplate = "prompts/advisory/remediation.liquid",
|
||||
Budget = new AdvisoryTaskBudgetModel
|
||||
{
|
||||
PromptTokens = 192,
|
||||
CompletionTokens = 96
|
||||
},
|
||||
Chunks = Array.Empty<PipelineChunkSummaryModel>(),
|
||||
Vectors = Array.Empty<PipelineVectorSummaryModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var outputResponse = new AdvisoryPipelineOutputModel
|
||||
{
|
||||
CacheKey = planResponse.CacheKey,
|
||||
TaskType = planResponse.TaskType,
|
||||
Profile = "default",
|
||||
Prompt = "Sanitized prompt",
|
||||
Response = "Rendered remediation body.",
|
||||
Citations = new[]
|
||||
{
|
||||
new AdvisoryOutputCitationModel { Index = 1, DocumentId = "doc-77", ChunkId = "chunk-77" }
|
||||
},
|
||||
Metadata = new Dictionary<string, string>(),
|
||||
Guardrail = new AdvisoryOutputGuardrailModel
|
||||
{
|
||||
Blocked = false,
|
||||
SanitizedPrompt = "Sanitized prompt",
|
||||
Violations = Array.Empty<AdvisoryOutputGuardrailViolationModel>(),
|
||||
Metadata = new Dictionary<string, string>()
|
||||
},
|
||||
Provenance = new AdvisoryOutputProvenanceModel
|
||||
{
|
||||
InputDigest = "sha256:remediation-in",
|
||||
OutputHash = "sha256:remediation-out",
|
||||
Signatures = Array.Empty<string>()
|
||||
},
|
||||
GeneratedAtUtc = DateTimeOffset.Parse("2025-11-06T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
PlanFromCache = false
|
||||
};
|
||||
|
||||
var backend = new StubBackendClient(new JobTriggerResult(true, "ok", null, null))
|
||||
{
|
||||
AdvisoryPlanResponse = planResponse,
|
||||
AdvisoryOutputResponse = outputResponse
|
||||
};
|
||||
|
||||
var provider = BuildServiceProvider(backend);
|
||||
var outputPath = Path.GetTempFileName();
|
||||
|
||||
await CommandHandlers.HandleAdviseRunAsync(
|
||||
provider,
|
||||
AdvisoryAiTaskType.Remediation,
|
||||
"ADV-77",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"default",
|
||||
Array.Empty<string>(),
|
||||
forceRefresh: false,
|
||||
timeoutSeconds: 0,
|
||||
outputFormat: AdvisoryOutputFormat.Markdown,
|
||||
outputPath: outputPath,
|
||||
verbose: false,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
var markdown = await File.ReadAllTextAsync(outputPath);
|
||||
Assert.Contains("Remediation", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Rendered remediation body", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("doc-77", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("chunk-77", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Citations", markdown, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(0, Environment.ExitCode);
|
||||
Assert.Contains("Remediation", testConsole.Output, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(AdvisoryAiTaskType.Remediation, backend.AdvisoryPlanRequests.Last().TaskType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
AnsiConsole.Console = originalConsole;
|
||||
Environment.ExitCode = originalExit;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleAdviseRunAsync_ReturnsGuardrailExitCodeOnBlock()
|
||||
{
|
||||
@@ -3776,6 +3972,7 @@ spec:
|
||||
Array.Empty<TaskRunnerSimulationOutput>(),
|
||||
false);
|
||||
public Exception? TaskRunnerSimulationException { get; set; }
|
||||
public OfflineKitStatus? OfflineStatus { get; set; }
|
||||
public PolicyActivationResult ActivationResult { get; set; } = new PolicyActivationResult(
|
||||
"activated",
|
||||
new PolicyActivationRevision(
|
||||
@@ -3966,7 +4163,19 @@ spec:
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public Task<OfflineKitStatus> GetOfflineKitStatusAsync(CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
{
|
||||
return Task.FromResult(OfflineStatus ?? new OfflineKitStatus(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Array.Empty<OfflineKitComponentStatus>()));
|
||||
}
|
||||
|
||||
public Task<EntryTraceResponseModel?> GetEntryTraceAsync(string scanId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ public sealed record LnmLinksetPage(
|
||||
public sealed record LnmLinksetNormalized(
|
||||
[property: JsonPropertyName("aliases")] IReadOnlyList<string>? Aliases,
|
||||
[property: JsonPropertyName("purl")] IReadOnlyList<string>? Purl,
|
||||
[property: JsonPropertyName("cpe")] IReadOnlyList<string>? Cpe,
|
||||
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions,
|
||||
[property: JsonPropertyName("ranges")] IReadOnlyList<object>? Ranges,
|
||||
[property: JsonPropertyName("severities")] IReadOnlyList<object>? Severities);
|
||||
|
||||
@@ -1752,6 +1752,9 @@ LnmLinksetResponse ToLnmResponse(
|
||||
bool includeObservations)
|
||||
{
|
||||
var normalized = linkset.Normalized;
|
||||
var severity = normalized?.Severities?.FirstOrDefault() is { } severityDict
|
||||
? ExtractSeverity(severityDict)
|
||||
: null;
|
||||
var conflicts = includeConflicts
|
||||
? (linkset.Conflicts ?? Array.Empty<AdvisoryLinksetConflict>()).Select(c =>
|
||||
new LnmLinksetConflict(
|
||||
@@ -1764,7 +1767,13 @@ LnmLinksetResponse ToLnmResponse(
|
||||
: Array.Empty<LnmLinksetConflict>();
|
||||
|
||||
var timeline = includeTimeline
|
||||
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
|
||||
? new[]
|
||||
{
|
||||
new LnmLinksetTimeline(
|
||||
Event: "created",
|
||||
At: linkset.CreatedAt,
|
||||
EvidenceHash: linkset.Provenance?.ObservationHashes?.FirstOrDefault())
|
||||
}
|
||||
: Array.Empty<LnmLinksetTimeline>();
|
||||
|
||||
var provenance = linkset.Provenance is null
|
||||
@@ -1780,6 +1789,7 @@ LnmLinksetResponse ToLnmResponse(
|
||||
: new LnmLinksetNormalized(
|
||||
Aliases: null,
|
||||
Purl: normalized.Purls,
|
||||
Cpe: normalized.Cpes,
|
||||
Versions: normalized.Versions,
|
||||
Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(),
|
||||
Severities: normalized.Severities?.Select(s => (object)s).ToArray());
|
||||
@@ -1788,11 +1798,11 @@ LnmLinksetResponse ToLnmResponse(
|
||||
linkset.AdvisoryId,
|
||||
linkset.Source,
|
||||
normalized?.Purls ?? Array.Empty<string>(),
|
||||
Array.Empty<string>(),
|
||||
normalized?.Cpes ?? Array.Empty<string>(),
|
||||
Summary: null,
|
||||
PublishedAt: linkset.CreatedAt,
|
||||
ModifiedAt: linkset.CreatedAt,
|
||||
Severity: null,
|
||||
Severity: severity,
|
||||
Status: "fact-only",
|
||||
provenance,
|
||||
conflicts,
|
||||
@@ -1803,6 +1813,27 @@ LnmLinksetResponse ToLnmResponse(
|
||||
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
|
||||
}
|
||||
|
||||
string? ExtractSeverity(IReadOnlyDictionary<string, object?> severityDict)
|
||||
{
|
||||
if (severityDict.TryGetValue("system", out var systemObj) && systemObj is string system && !string.IsNullOrWhiteSpace(system) &&
|
||||
severityDict.TryGetValue("score", out var scoreObj))
|
||||
{
|
||||
return $"{system}:{scoreObj}";
|
||||
}
|
||||
|
||||
if (severityDict.TryGetValue("score", out var scoreOnly) && scoreOnly is not null)
|
||||
{
|
||||
return scoreOnly.ToString();
|
||||
}
|
||||
|
||||
if (severityDict.TryGetValue("value", out var value) && value is string valueString && !string.IsNullOrWhiteSpace(valueString))
|
||||
{
|
||||
return valueString;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
IResult JsonResult<T>(T value, int? statusCode = null)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, Program.JsonOptions);
|
||||
|
||||
@@ -241,6 +241,7 @@ components:
|
||||
properties:
|
||||
aliases: { type: array, items: { type: string } }
|
||||
purl: { type: array, items: { type: string } }
|
||||
cpe: { type: array, items: { type: string } }
|
||||
versions: { type: array, items: { type: string } }
|
||||
ranges: { type: array, items: { type: object } }
|
||||
severities: { type: array, items: { type: object } }
|
||||
|
||||
@@ -20,10 +20,14 @@ public sealed record AdvisoryLinkset(
|
||||
|
||||
public sealed record AdvisoryLinksetNormalized(
|
||||
IReadOnlyList<string>? Purls,
|
||||
IReadOnlyList<string>? Cpes,
|
||||
IReadOnlyList<string>? Versions,
|
||||
IReadOnlyList<Dictionary<string, object?>>? Ranges,
|
||||
IReadOnlyList<Dictionary<string, object?>>? Severities)
|
||||
{
|
||||
public List<string>? CpesToList()
|
||||
=> Cpes is null ? null : Cpes.ToList();
|
||||
|
||||
public List<BsonDocument>? RangesToBson()
|
||||
=> Ranges is null ? null : Ranges.Select(BsonDocumentHelper.FromDictionary).ToList();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ internal static class AdvisoryLinksetNormalization
|
||||
public static AdvisoryLinksetNormalized? FromRawLinkset(RawLinkset linkset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
return Build(linkset.PackageUrls);
|
||||
return Build(linkset.PackageUrls, linkset.Cpes);
|
||||
}
|
||||
|
||||
public static AdvisoryLinksetNormalized? FromPurls(IEnumerable<string>? purls)
|
||||
@@ -22,7 +22,7 @@ internal static class AdvisoryLinksetNormalization
|
||||
return null;
|
||||
}
|
||||
|
||||
return Build(purls);
|
||||
return Build(purls, Enumerable.Empty<string>());
|
||||
}
|
||||
|
||||
public static (AdvisoryLinksetNormalized? normalized, double? confidence, IReadOnlyList<AdvisoryLinksetConflict> conflicts) FromRawLinksetWithConfidence(
|
||||
@@ -31,7 +31,7 @@ internal static class AdvisoryLinksetNormalization
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(linkset);
|
||||
|
||||
var normalized = Build(linkset.PackageUrls);
|
||||
var normalized = Build(linkset.PackageUrls, linkset.Cpes);
|
||||
|
||||
var inputs = new[]
|
||||
{
|
||||
@@ -51,18 +51,19 @@ internal static class AdvisoryLinksetNormalization
|
||||
return (normalized, coerced, conflicts);
|
||||
}
|
||||
|
||||
private static AdvisoryLinksetNormalized? Build(IEnumerable<string> purlValues)
|
||||
private static AdvisoryLinksetNormalized? Build(IEnumerable<string> purlValues, IEnumerable<string>? cpeValues)
|
||||
{
|
||||
var normalizedPurls = NormalizePurls(purlValues);
|
||||
var normalizedCpes = NormalizeCpes(cpeValues);
|
||||
var versions = ExtractVersions(normalizedPurls);
|
||||
var ranges = BuildVersionRanges(normalizedPurls);
|
||||
|
||||
if (normalizedPurls.Count == 0 && versions.Count == 0 && ranges.Count == 0)
|
||||
if (normalizedPurls.Count == 0 && normalizedCpes.Count == 0 && versions.Count == 0 && ranges.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AdvisoryLinksetNormalized(normalizedPurls, versions, ranges, null);
|
||||
return new AdvisoryLinksetNormalized(normalizedPurls, normalizedCpes, versions, ranges, null);
|
||||
}
|
||||
|
||||
private static List<string> NormalizePurls(IEnumerable<string> purls)
|
||||
@@ -147,6 +148,31 @@ internal static class AdvisoryLinksetNormalization
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeCpes(IEnumerable<string>? cpes)
|
||||
{
|
||||
if (cpes is null)
|
||||
{
|
||||
return new List<string>(capacity: 0);
|
||||
}
|
||||
|
||||
var distinct = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var cpe in cpes)
|
||||
{
|
||||
var normalized = Validation.TrimToNull(cpe);
|
||||
if (normalized is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LinksetNormalization.TryNormalizeCpe(normalized, out var canonical) && !string.IsNullOrEmpty(canonical))
|
||||
{
|
||||
distinct.Add(canonical);
|
||||
}
|
||||
}
|
||||
|
||||
return distinct.ToList();
|
||||
}
|
||||
|
||||
private static bool LooksLikeRange(string value)
|
||||
{
|
||||
return value.IndexOfAny(new[] { '^', '~', '*', ' ', ',', '|', '>' , '<' }) >= 0 ||
|
||||
|
||||
@@ -61,6 +61,11 @@ public sealed class AdvisoryLinksetNormalizedDocument
|
||||
public List<string>? Purls { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("cpes")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Cpes { get; set; }
|
||||
= new();
|
||||
|
||||
[BsonElement("versions")]
|
||||
[BsonIgnoreIfNull]
|
||||
public List<string>? Versions { get; set; }
|
||||
|
||||
@@ -125,6 +125,7 @@ internal sealed class ConcelierMongoLinksetStore : IMongoAdvisoryLinksetStore
|
||||
Normalized = linkset.Normalized is null ? null : new AdvisoryLinksetNormalizedDocument
|
||||
{
|
||||
Purls = linkset.Normalized.Purls is null ? null : new List<string>(linkset.Normalized.Purls),
|
||||
Cpes = linkset.Normalized.Cpes is null ? null : new List<string>(linkset.Normalized.Cpes),
|
||||
Versions = linkset.Normalized.Versions is null ? null : new List<string>(linkset.Normalized.Versions),
|
||||
Ranges = linkset.Normalized.RangesToBson(),
|
||||
Severities = linkset.Normalized.SeveritiesToBson(),
|
||||
@@ -141,6 +142,7 @@ internal sealed class ConcelierMongoLinksetStore : IMongoAdvisoryLinksetStore
|
||||
doc.Observations.ToImmutableArray(),
|
||||
doc.Normalized is null ? null : new CoreLinksets.AdvisoryLinksetNormalized(
|
||||
doc.Normalized.Purls,
|
||||
doc.Normalized.Cpes,
|
||||
doc.Normalized.Versions,
|
||||
doc.Normalized.Ranges?.Select(ToDictionary).ToList(),
|
||||
doc.Normalized.Severities?.Select(ToDictionary).ToList()),
|
||||
|
||||
@@ -214,6 +214,7 @@ internal sealed class EnsureLinkNotMergeCollectionsMigration : IMongoMigration
|
||||
{ "properties", new BsonDocument
|
||||
{
|
||||
{ "purls", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
|
||||
{ "cpes", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
|
||||
{ "versions", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "string") } } },
|
||||
{ "ranges", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "object") } } },
|
||||
{ "severities", new BsonDocument { { "bsonType", new BsonArray { "array", "null" } }, { "items", new BsonDocument("bsonType", "object") } } }
|
||||
|
||||
@@ -14,17 +14,17 @@ public sealed class AdvisoryLinksetQueryServiceTests
|
||||
{
|
||||
new("tenant", "ghsa", "adv-003",
|
||||
ImmutableArray.Create("obs-003"),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, new[]{"1.0.0"}, null, null),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/a"}, null, new[]{"1.0.0"}, null, null),
|
||||
null, null, null,
|
||||
DateTimeOffset.Parse("2025-11-10T12:00:00Z"), null),
|
||||
new("tenant", "ghsa", "adv-002",
|
||||
ImmutableArray.Create("obs-002"),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, new[]{"2.0.0"}, null, null),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/b"}, null, new[]{"2.0.0"}, null, null),
|
||||
null, null, null,
|
||||
DateTimeOffset.Parse("2025-11-09T12:00:00Z"), null),
|
||||
new("tenant", "ghsa", "adv-001",
|
||||
ImmutableArray.Create("obs-001"),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, new[]{"3.0.0"}, null, null),
|
||||
new AdvisoryLinksetNormalized(new[]{"pkg:npm/c"}, null, new[]{"3.0.0"}, null, null),
|
||||
null, null, null,
|
||||
DateTimeOffset.Parse("2025-11-08T12:00:00Z"), null),
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ public class PolicyAuthSignalFactoryTests
|
||||
ObservationIds: ImmutableArray.Create("obs-1"),
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "purl:pkg:maven/org.example/app@1.2.3" },
|
||||
Cpes: null,
|
||||
Versions: Array.Empty<string>(),
|
||||
Ranges: null,
|
||||
Severities: null),
|
||||
|
||||
@@ -18,6 +18,7 @@ public class AdvisorySummaryMapperTests
|
||||
ObservationIds: ImmutableArray.Create("obs1", "obs2"),
|
||||
Normalized: new AdvisoryLinksetNormalized(
|
||||
Purls: new[] { "pkg:maven/log4j/log4j@2.17.1" },
|
||||
Cpes: null,
|
||||
Versions: null,
|
||||
Ranges: null,
|
||||
Severities: null),
|
||||
|
||||
@@ -36,18 +36,28 @@
|
||||
<PackageReference Include="SharpCompress" Version="0.41.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'">
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Mongo2Go" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" />
|
||||
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
|
||||
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
|
||||
<ProjectReference Include="$(ConcelierTestingPath)StellaOps.Concelier.Testing.csproj" Condition="'$(ConcelierTestingPath)' != ''" />
|
||||
<Using Include="StellaOps.Concelier.Testing" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
<Compile Include="$(ConcelierSharedTestsPath)AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
|
||||
<Compile Include="$(ConcelierSharedTestsPath)MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" Condition="'$(ConcelierSharedTestsPath)' != ''" />
|
||||
<ProjectReference Include="$(ConcelierTestingPath)StellaOps.Concelier.Testing.csproj" Condition="'$(ConcelierTestingPath)' != ''" />
|
||||
<Using Include="StellaOps.Concelier.Testing" />
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- DEVOPS-OPENSSL-11-001: ship OpenSSL 1.1 shim with test outputs for Mongo2Go on Linux -->
|
||||
<ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests'))">
|
||||
<None Include="$(MSBuildThisFileDirectory)..\tests\native\openssl-1.1\linux-x64\*.so.1.1"
|
||||
Link="native/linux-x64/%(Filename)%(Extension)"
|
||||
CopyToOutputDirectory="PreserveNewest" />
|
||||
<!-- DEVOPS-OPENSSL-11-002: auto-enable shim at test start for Mongo2Go suites -->
|
||||
<Compile Include="$(MSBuildThisFileDirectory)..\tests\shared\OpenSslLegacyShim.cs" Link="Shared/OpenSslLegacyShim.cs" />
|
||||
<Compile Include="$(MSBuildThisFileDirectory)..\tests\shared\OpenSslAutoInit.cs" Link="Shared/OpenSslAutoInit.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
public sealed record GraphOverlaysResponse(
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<GraphOverlayItem> Items,
|
||||
[property: JsonPropertyName("cached")] bool Cached,
|
||||
[property: JsonPropertyName("cacheAgeMs")] long? CacheAgeMs);
|
||||
|
||||
public sealed record GraphOverlayItem(
|
||||
[property: JsonPropertyName("purl")] string Purl,
|
||||
[property: JsonPropertyName("summary")] GraphOverlaySummary Summary,
|
||||
[property: JsonPropertyName("latestModifiedAt")] DateTimeOffset? LatestModifiedAt,
|
||||
[property: JsonPropertyName("justifications")] IReadOnlyList<string> Justifications,
|
||||
[property: JsonPropertyName("provenance")] GraphOverlayProvenance Provenance);
|
||||
|
||||
public sealed record GraphOverlaySummary(
|
||||
[property: JsonPropertyName("open")] int Open,
|
||||
[property: JsonPropertyName("not_affected")] int NotAffected,
|
||||
[property: JsonPropertyName("under_investigation")] int UnderInvestigation,
|
||||
[property: JsonPropertyName("no_statement")] int NoStatement);
|
||||
|
||||
public sealed record GraphOverlayProvenance(
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<string> Sources,
|
||||
[property: JsonPropertyName("lastEvidenceHash")] string? LastEvidenceHash);
|
||||
@@ -1,58 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.WebService.Options;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("v1/graph")]
|
||||
public class GraphController : ControllerBase
|
||||
{
|
||||
private readonly GraphOptions _options;
|
||||
|
||||
public GraphController(IOptions<GraphOptions> options)
|
||||
{
|
||||
_options = options.Value;
|
||||
}
|
||||
|
||||
[HttpPost("linkouts")]
|
||||
public IActionResult Linkouts([FromBody] LinkoutRequest request)
|
||||
{
|
||||
if (request == null || request.Purls == null || request.Purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purls are required");
|
||||
}
|
||||
|
||||
if (request.Purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph linkouts pending storage integration.");
|
||||
}
|
||||
|
||||
[HttpGet("overlays")]
|
||||
public IActionResult Overlays([FromQuery(Name = "purl")] List<string> purls, [FromQuery] bool includeJustifications = false)
|
||||
{
|
||||
if (purls == null || purls.Count == 0)
|
||||
{
|
||||
return BadRequest("purl query parameter is required");
|
||||
}
|
||||
|
||||
if (purls.Count > _options.MaxPurls)
|
||||
{
|
||||
return BadRequest($"purls limit exceeded (max {_options.MaxPurls})");
|
||||
}
|
||||
|
||||
return StatusCode(503, "Graph overlays pending storage integration.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LinkoutRequest
|
||||
{
|
||||
public string Tenant { get; init; } = string.Empty;
|
||||
public List<string> Purls { get; init; } = new();
|
||||
public bool IncludeJustifications { get; init; }
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Graph;
|
||||
|
||||
internal static class GraphOverlayFactory
|
||||
{
|
||||
public static IReadOnlyList<GraphOverlayItem> Build(
|
||||
IReadOnlyList<string> orderedPurls,
|
||||
IReadOnlyList<VexObservation> observations,
|
||||
bool includeJustifications)
|
||||
{
|
||||
if (orderedPurls is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(orderedPurls));
|
||||
}
|
||||
|
||||
if (observations is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(observations));
|
||||
}
|
||||
|
||||
var observationsByPurl = observations
|
||||
.SelectMany(obs => obs.Linkset.Purls.Select(purl => (purl, obs)))
|
||||
.GroupBy(tuple => tuple.purl, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.Select(t => t.obs).ToImmutableArray(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var items = new List<GraphOverlayItem>(orderedPurls.Count);
|
||||
|
||||
foreach (var input in orderedPurls)
|
||||
{
|
||||
if (!observationsByPurl.TryGetValue(input, out var obsForPurl) || obsForPurl.Length == 0)
|
||||
{
|
||||
items.Add(new GraphOverlayItem(
|
||||
Purl: input,
|
||||
Summary: new GraphOverlaySummary(0, 0, 0, 0),
|
||||
LatestModifiedAt: null,
|
||||
Justifications: Array.Empty<string>(),
|
||||
Provenance: new GraphOverlayProvenance(Array.Empty<string>(), null)));
|
||||
continue;
|
||||
}
|
||||
|
||||
var open = 0;
|
||||
var notAffected = 0;
|
||||
var underInvestigation = 0;
|
||||
var noStatement = 0;
|
||||
var justifications = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var sources = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? lastEvidenceHash = null;
|
||||
DateTimeOffset? latestModifiedAt = null;
|
||||
|
||||
foreach (var obs in obsForPurl)
|
||||
{
|
||||
sources.Add(obs.ProviderId);
|
||||
if (latestModifiedAt is null || obs.CreatedAt > latestModifiedAt.Value)
|
||||
{
|
||||
latestModifiedAt = obs.CreatedAt;
|
||||
lastEvidenceHash = obs.Upstream.ContentHash;
|
||||
}
|
||||
|
||||
var matchingStatements = obs.Statements
|
||||
.Where(stmt => PurlMatches(stmt, input, obs.Linkset.Purls))
|
||||
.ToArray();
|
||||
|
||||
if (matchingStatements.Length == 0)
|
||||
{
|
||||
noStatement++;
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var stmt in matchingStatements)
|
||||
{
|
||||
switch (stmt.Status)
|
||||
{
|
||||
case VexClaimStatus.NotAffected:
|
||||
notAffected++;
|
||||
break;
|
||||
case VexClaimStatus.UnderInvestigation:
|
||||
underInvestigation++;
|
||||
break;
|
||||
default:
|
||||
open++;
|
||||
break;
|
||||
}
|
||||
|
||||
if (includeJustifications && stmt.Justification is not null)
|
||||
{
|
||||
justifications.Add(stmt.Justification!.ToString()!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.Add(new GraphOverlayItem(
|
||||
Purl: input,
|
||||
Summary: new GraphOverlaySummary(open, notAffected, underInvestigation, noStatement),
|
||||
LatestModifiedAt: latestModifiedAt,
|
||||
Justifications: includeJustifications
|
||||
? justifications.ToArray()
|
||||
: Array.Empty<string>(),
|
||||
Provenance: new GraphOverlayProvenance(sources.ToArray(), lastEvidenceHash)));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private static bool PurlMatches(VexObservationStatement stmt, string inputPurl, ImmutableArray<string> linksetPurls)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(stmt.Purl) && stmt.Purl.Equals(inputPurl, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (linksetPurls.IsDefaultOrEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return linksetPurls.Any(p => p.Equals(inputPurl, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,33 @@ public partial class Program
|
||||
return Math.Clamp(parsed, min, max);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizePurls(string[]? purls)
|
||||
{
|
||||
if (purls is null || purls.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var ordered = new List<string>(purls.Length);
|
||||
foreach (var purl in purls)
|
||||
{
|
||||
var trimmed = purl?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalized = trimmed.ToLowerInvariant();
|
||||
if (seen.Add(normalized))
|
||||
{
|
||||
ordered.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static VexObservationStatementResponse ToResponse(VexObservationStatementProjection projection)
|
||||
{
|
||||
var scope = projection.Scope;
|
||||
@@ -234,4 +261,8 @@ public partial class Program
|
||||
signature.Issuer,
|
||||
signature.VerifiedAt));
|
||||
}
|
||||
|
||||
private sealed record CachedGraphOverlay(
|
||||
IReadOnlyList<GraphOverlayItem> Items,
|
||||
DateTimeOffset CachedAt);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ using MongoDB.Driver;
|
||||
using MongoDB.Bson;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using StellaOps.Excititor.WebService.Contracts;
|
||||
using StellaOps.Excititor.WebService.Graph;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var configuration = builder.Configuration;
|
||||
@@ -523,10 +524,70 @@ var options = new VexObservationQueryOptions(
|
||||
NextCursor: advisories.Count >= 200 ? $"{advisories[^1].AdvisoryId}:{advisories[^1].Source}" : null));
|
||||
}
|
||||
|
||||
var response = new GraphLinkoutsResponse(items, notFound);
|
||||
var response = new GraphLinkoutsResponse(items, notFound);
|
||||
return Results.Ok(response);
|
||||
}).WithName("PostGraphLinkouts");
|
||||
|
||||
// Cartographer overlays
|
||||
app.MapGet("/v1/graph/overlays", async (
|
||||
HttpContext context,
|
||||
[FromQuery(Name = "purl")] string[]? purls,
|
||||
[FromQuery] bool includeJustifications,
|
||||
IOptions<VexMongoStorageOptions> storageOptions,
|
||||
IOptions<GraphOptions> graphOptions,
|
||||
IVexObservationQueryService queryService,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: true, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
var orderedPurls = NormalizePurls(purls);
|
||||
if (orderedPurls.Count == 0)
|
||||
{
|
||||
return Results.BadRequest("purl query parameter is required");
|
||||
}
|
||||
|
||||
if (orderedPurls.Count > graphOptions.Value.MaxPurls)
|
||||
{
|
||||
return Results.BadRequest($"purls limit exceeded (max {graphOptions.Value.MaxPurls})");
|
||||
}
|
||||
|
||||
var cacheKey = $"graph-overlays:{tenant}:{includeJustifications}:{string.Join('|', orderedPurls)}";
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
if (cache.TryGetValue<CachedGraphOverlay>(cacheKey, out var cached) && cached is not null)
|
||||
{
|
||||
var ageMs = (long)Math.Max(0, (now - cached.CachedAt).TotalMilliseconds);
|
||||
return Results.Ok(new GraphOverlaysResponse(cached.Items, true, ageMs));
|
||||
}
|
||||
|
||||
var options = new VexObservationQueryOptions(
|
||||
tenant: tenant,
|
||||
purls: orderedPurls,
|
||||
limit: graphOptions.Value.MaxAdvisoriesPerPurl * orderedPurls.Count);
|
||||
|
||||
VexObservationQueryResult result;
|
||||
try
|
||||
{
|
||||
result = await queryService.QueryAsync(options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Results.BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
var overlays = GraphOverlayFactory.Build(orderedPurls, result.Observations, includeJustifications);
|
||||
var response = new GraphOverlaysResponse(overlays, false, null);
|
||||
|
||||
cache.Set(cacheKey, new CachedGraphOverlay(overlays, now), TimeSpan.FromSeconds(graphOptions.Value.OverlayTtlSeconds));
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetGraphOverlays");
|
||||
|
||||
app.MapPost("/ingest/vex", async (
|
||||
HttpContext context,
|
||||
VexIngestRequest request,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Excititor.Core;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.WebService.Graph;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Tests;
|
||||
|
||||
public sealed class GraphOverlayFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Build_ComputesSummariesAndProvenancePerPurl()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var observations = new[]
|
||||
{
|
||||
CreateObservation(
|
||||
providerId: "redhat",
|
||||
createdAt: now.AddMinutes(-5),
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: new[]
|
||||
{
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-1000",
|
||||
productKey: "pkg:rpm/redhat/openssl@1.1.1",
|
||||
status: VexClaimStatus.NotAffected,
|
||||
lastObserved: now,
|
||||
justification: VexJustification.ComponentNotPresent,
|
||||
purl: "pkg:rpm/redhat/openssl@1.1.1")
|
||||
},
|
||||
contentHash: "hash-old"),
|
||||
CreateObservation(
|
||||
providerId: "ubuntu",
|
||||
createdAt: now,
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: new[]
|
||||
{
|
||||
new VexObservationStatement(
|
||||
vulnerabilityId: "CVE-2025-1001",
|
||||
productKey: "pkg:rpm/redhat/openssl@1.1.1",
|
||||
status: VexClaimStatus.UnderInvestigation,
|
||||
lastObserved: now,
|
||||
justification: null,
|
||||
purl: "pkg:rpm/redhat/openssl@1.1.1")
|
||||
},
|
||||
contentHash: "hash-new"),
|
||||
CreateObservation(
|
||||
providerId: "oracle",
|
||||
createdAt: now.AddMinutes(-1),
|
||||
purls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
statements: Array.Empty<VexObservationStatement>(),
|
||||
contentHash: "hash-oracle")
|
||||
};
|
||||
|
||||
var overlays = GraphOverlayFactory.Build(
|
||||
orderedPurls: new[] { "pkg:rpm/redhat/openssl@1.1.1" },
|
||||
observations: observations,
|
||||
includeJustifications: true);
|
||||
|
||||
var overlay = Assert.Single(overlays);
|
||||
Assert.Equal("pkg:rpm/redhat/openssl@1.1.1", overlay.Purl);
|
||||
Assert.Equal(0, overlay.Summary.Open);
|
||||
Assert.Equal(1, overlay.Summary.NotAffected);
|
||||
Assert.Equal(1, overlay.Summary.UnderInvestigation);
|
||||
Assert.Equal(1, overlay.Summary.NoStatement);
|
||||
Assert.Equal(now, overlay.LatestModifiedAt);
|
||||
Assert.Equal(new[] { "ComponentNotPresent" }, overlay.Justifications);
|
||||
Assert.Equal("hash-new", overlay.Provenance.LastEvidenceHash);
|
||||
Assert.Equal(new[] { "oracle", "redhat", "ubuntu" }, overlay.Provenance.Sources);
|
||||
}
|
||||
|
||||
private static VexObservation CreateObservation(
|
||||
string providerId,
|
||||
DateTimeOffset createdAt,
|
||||
string[] purls,
|
||||
VexObservationStatement[] statements,
|
||||
string contentHash)
|
||||
{
|
||||
return new VexObservation(
|
||||
observationId: $"obs-{providerId}-{createdAt.ToUnixTimeMilliseconds()}",
|
||||
tenant: "tenant-a",
|
||||
providerId: providerId,
|
||||
streamId: "csaf",
|
||||
upstream: new VexObservationUpstream(
|
||||
upstreamId: Guid.NewGuid().ToString("N"),
|
||||
documentVersion: "1",
|
||||
fetchedAt: createdAt,
|
||||
receivedAt: createdAt,
|
||||
contentHash: contentHash,
|
||||
signature: new VexObservationSignature(present: true, format: "sig", keyId: null, signature: null)),
|
||||
statements: statements.ToImmutableArray(),
|
||||
content: new VexObservationContent(
|
||||
format: "csaf",
|
||||
specVersion: "1",
|
||||
raw: JsonValue.Create("raw")!,
|
||||
metadata: ImmutableDictionary<string, string>.Empty),
|
||||
linkset: new VexObservationLinkset(
|
||||
aliases: Array.Empty<string>(),
|
||||
purls: purls,
|
||||
cpes: Array.Empty<string>(),
|
||||
references: Array.Empty<VexObservationReference>()),
|
||||
createdAt: createdAt,
|
||||
supersedes: ImmutableArray<string>.Empty,
|
||||
attributes: ImmutableDictionary<string, string>.Empty);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@
|
||||
<Compile Include="TestAuthentication.cs" />
|
||||
<Compile Include="TestServiceOverrides.cs" />
|
||||
<Compile Include="TestWebApplicationFactory.cs" />
|
||||
<Compile Include="GraphOverlayFactoryTests.cs" />
|
||||
<Compile Include="AttestationVerifyEndpointTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -13,7 +13,10 @@ cat > "$STAGE/layers/observations.ndjson" <<'DATA'
|
||||
{"id":"obs-002","purl":"pkg:npm/lodash@4.17.21","advisory":"CVE-2024-9999","severity":"high","source":"vendor-b","timestamp":"2025-10-15T00:00:00Z"}
|
||||
DATA
|
||||
|
||||
cat > "$STAGE/layers/time-anchor.json" <<'DATA'
|
||||
if [[ -n "${TIME_ANCHOR_FILE:-}" && -f "${TIME_ANCHOR_FILE}" ]]; then
|
||||
cp "${TIME_ANCHOR_FILE}" "$STAGE/layers/time-anchor.json"
|
||||
else
|
||||
cat > "$STAGE/layers/time-anchor.json" <<'DATA'
|
||||
{
|
||||
"authority": "stellaops-airgap-test",
|
||||
"generatedAt": "2025-11-01T00:00:00Z",
|
||||
@@ -29,6 +32,7 @@ cat > "$STAGE/layers/time-anchor.json" <<'DATA'
|
||||
]
|
||||
}
|
||||
DATA
|
||||
fi
|
||||
|
||||
cat > "$STAGE/indexes/observations.index" <<'DATA'
|
||||
obs-001 layers/observations.ndjson:1
|
||||
|
||||
@@ -10,26 +10,16 @@ using Xunit;
|
||||
|
||||
namespace StellaOps.Notifier.Tests;
|
||||
|
||||
public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<WebServiceAssemblyMarker>>
|
||||
public sealed class OpenApiEndpointTests : IClassFixture<NotifierApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
private readonly InMemoryPackApprovalRepository _packRepo = new();
|
||||
private readonly InMemoryLockRepository _lockRepo = new();
|
||||
private readonly InMemoryAuditRepository _auditRepo = new();
|
||||
|
||||
public OpenApiEndpointTests(WebApplicationFactory<WebServiceAssemblyMarker> factory)
|
||||
public OpenApiEndpointTests(NotifierApplicationFactory factory)
|
||||
{
|
||||
_client = factory
|
||||
.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
|
||||
services.AddSingleton<INotifyLockRepository>(_lockRepo);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
|
||||
});
|
||||
})
|
||||
.CreateClient();
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,4 +79,20 @@ public sealed class OpenApiEndpointTests : IClassFixture<WebApplicationFactory<W
|
||||
resumeValues.Contains("rt-ok"));
|
||||
Assert.True(_packRepo.Exists("tenant-a", Guid.Parse("00000000-0000-0000-0000-000000000002"), "offline-kit"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PackApprovals_acknowledgement_requires_tenant_and_token()
|
||||
{
|
||||
var ackContent = new StringContent("""{"ackToken":"token-123"}""", Encoding.UTF8, "application/json");
|
||||
var ackResponse = await _client.PostAsync("/api/v1/notify/pack-approvals/offline-kit/ack", ackContent, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, ackResponse.StatusCode);
|
||||
|
||||
var ackReq = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/pack-approvals/offline-kit/ack")
|
||||
{
|
||||
Content = ackContent
|
||||
};
|
||||
ackReq.Headers.Add("X-StellaOps-Tenant", "tenant-a");
|
||||
var goodResponse = await _client.SendAsync(ackReq, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, goodResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="TestContent/**" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notifier.WebService;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.Tests.Support;
|
||||
|
||||
internal sealed class NotifierApplicationFactory : WebApplicationFactory<WebServiceAssemblyMarker>
|
||||
{
|
||||
private readonly InMemoryPackApprovalRepository _packRepo;
|
||||
private readonly InMemoryLockRepository _lockRepo;
|
||||
private readonly InMemoryAuditRepository _auditRepo;
|
||||
|
||||
public NotifierApplicationFactory(
|
||||
InMemoryPackApprovalRepository packRepo,
|
||||
InMemoryLockRepository lockRepo,
|
||||
InMemoryAuditRepository auditRepo)
|
||||
{
|
||||
_packRepo = packRepo;
|
||||
_lockRepo = lockRepo;
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseContentRoot(Path.Combine(Directory.GetCurrentDirectory(), "TestContent"));
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<INotifyPackApprovalRepository>(_packRepo);
|
||||
services.AddSingleton<INotifyLockRepository>(_lockRepo);
|
||||
services.AddSingleton<INotifyAuditRepository>(_auditRepo);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
# OpenAPI 3.1 specification for StellaOps Notifier WebService (draft)
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Notifier API
|
||||
version: 0.6.0-draft
|
||||
description: |
|
||||
Contract for Notifications Studio (Notifier) covering rules, templates, incidents,
|
||||
and quiet hours. Uses the platform error envelope and tenant header `X-StellaOps-Tenant`.
|
||||
servers:
|
||||
- url: https://api.stellaops.example.com
|
||||
description: Production
|
||||
- url: https://api.dev.stellaops.example.com
|
||||
description: Development
|
||||
security:
|
||||
- oauth2: [notify.viewer]
|
||||
- oauth2: [notify.operator]
|
||||
- oauth2: [notify.admin]
|
||||
paths:
|
||||
/api/v1/notify/rules:
|
||||
get:
|
||||
summary: List notification rules
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- $ref: '#/components/parameters/PageToken'
|
||||
responses:
|
||||
'200':
|
||||
description: Paginated rule list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/NotifyRule' }
|
||||
nextPageToken:
|
||||
type: string
|
||||
examples:
|
||||
default:
|
||||
value:
|
||||
items:
|
||||
- ruleId: rule-critical
|
||||
tenantId: tenant-dev
|
||||
name: Critical scanner verdicts
|
||||
enabled: true
|
||||
match:
|
||||
eventKinds: [scanner.report.ready]
|
||||
minSeverity: critical
|
||||
actions:
|
||||
- actionId: act-slack-critical
|
||||
channel: chn-slack-soc
|
||||
template: tmpl-critical
|
||||
digest: instant
|
||||
nextPageToken: null
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Create a notification rule
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
examples:
|
||||
create-rule:
|
||||
value:
|
||||
ruleId: rule-attest-fail
|
||||
tenantId: tenant-dev
|
||||
name: Attestation failures → SOC
|
||||
enabled: true
|
||||
match:
|
||||
eventKinds: [attestor.verification.failed]
|
||||
actions:
|
||||
- actionId: act-soc
|
||||
channel: chn-webhook-soc
|
||||
template: tmpl-attest-verify-fail
|
||||
responses:
|
||||
'201':
|
||||
description: Rule created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/rules/{ruleId}:
|
||||
get:
|
||||
summary: Fetch a rule
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
responses:
|
||||
'200':
|
||||
description: Rule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
patch:
|
||||
summary: Update a rule (partial)
|
||||
tags: [Rules]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/RuleId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON Merge Patch
|
||||
responses:
|
||||
'200':
|
||||
description: Updated rule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyRule' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/templates:
|
||||
get:
|
||||
summary: List templates
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: key
|
||||
in: query
|
||||
description: Filter by template key
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Templates
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Create a template
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
responses:
|
||||
'201':
|
||||
description: Template created
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/templates/{templateId}:
|
||||
get:
|
||||
summary: Fetch a template
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/TemplateId'
|
||||
responses:
|
||||
'200':
|
||||
description: Template
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
patch:
|
||||
summary: Update a template (partial)
|
||||
tags: [Templates]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/TemplateId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: JSON Merge Patch
|
||||
responses:
|
||||
'200':
|
||||
description: Updated template
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NotifyTemplate' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/incidents:
|
||||
get:
|
||||
summary: List incidents (paged)
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/PageSize'
|
||||
- $ref: '#/components/parameters/PageToken'
|
||||
responses:
|
||||
'200':
|
||||
description: Incident page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Incident' }
|
||||
nextPageToken: { type: string }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Raise an incident (ops/toggle/override)
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Incident' }
|
||||
examples:
|
||||
start-incident:
|
||||
value:
|
||||
incidentId: inc-telemetry-outage
|
||||
kind: outage
|
||||
severity: major
|
||||
startedAt: 2025-11-17T04:02:00Z
|
||||
shortDescription: "Telemetry pipeline degraded; burn-rate breach"
|
||||
metadata:
|
||||
source: slo-evaluator
|
||||
responses:
|
||||
'202':
|
||||
description: Incident accepted
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/incidents/{incidentId}/ack:
|
||||
post:
|
||||
summary: Acknowledge an incident notification
|
||||
tags: [Incidents]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/IncidentId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ackToken:
|
||||
type: string
|
||||
description: DSSE-signed acknowledgement token
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledged
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/quiet-hours:
|
||||
get:
|
||||
summary: Get quiet-hours schedule
|
||||
tags: [QuietHours]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
responses:
|
||||
'200':
|
||||
description: Quiet hours schedule
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
examples:
|
||||
current:
|
||||
value:
|
||||
quietHoursId: qh-default
|
||||
windows:
|
||||
- timezone: UTC
|
||||
days: [Mon, Tue, Wed, Thu, Fri]
|
||||
start: "22:00"
|
||||
end: "06:00"
|
||||
exemptions:
|
||||
- eventKinds: [attestor.verification.failed]
|
||||
reason: "Always alert for attestation failures"
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
post:
|
||||
summary: Set quiet-hours schedule
|
||||
tags: [QuietHours]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
responses:
|
||||
'200':
|
||||
description: Updated quiet hours
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/QuietHours' }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/pack-approvals:
|
||||
post:
|
||||
summary: Ingest pack approval decision
|
||||
tags: [PackApprovals]
|
||||
operationId: ingestPackApproval
|
||||
security:
|
||||
- oauth2: [notify.operator]
|
||||
- hmac: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PackApprovalEvent' }
|
||||
examples:
|
||||
approval-granted:
|
||||
value:
|
||||
eventId: "20e4e5fe-3d4a-4f57-9f9b-b1a1c1111111"
|
||||
issuedAt: "2025-11-17T16:00:00Z"
|
||||
kind: "pack.approval.granted"
|
||||
packId: "offline-kit-2025-11"
|
||||
policy:
|
||||
id: "policy-123"
|
||||
version: "v5"
|
||||
decision: "approved"
|
||||
actor: "task-runner"
|
||||
resumeToken: "rt-abc123"
|
||||
summary: "All required attestations verified."
|
||||
labels:
|
||||
environment: "prod"
|
||||
approver: "ops"
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted; durable write queued for processing.
|
||||
headers:
|
||||
X-Resume-After:
|
||||
description: Resume token echo or replacement
|
||||
schema: { type: string }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/pack-approvals/{packId}/ack:
|
||||
post:
|
||||
summary: Acknowledge a pack approval notification
|
||||
tags: [PackApprovals]
|
||||
operationId: ackPackApproval
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ackToken: { type: string }
|
||||
required: [ackToken]
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledged
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
oauth2:
|
||||
type: oauth2
|
||||
flows:
|
||||
clientCredentials:
|
||||
tokenUrl: https://auth.stellaops.example.com/oauth/token
|
||||
scopes:
|
||||
notify.viewer: Read-only Notifier access
|
||||
notify.operator: Manage rules/templates/incidents within tenant
|
||||
notify.admin: Tenant-scoped administration
|
||||
hmac:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Pre-shared HMAC token (air-gap friendly) referenced by secretRef.
|
||||
parameters:
|
||||
Tenant:
|
||||
name: X-StellaOps-Tenant
|
||||
in: header
|
||||
required: true
|
||||
description: Tenant slug
|
||||
schema: { type: string }
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: Stable UUID to dedupe retries.
|
||||
schema: { type: string, format: uuid }
|
||||
PageSize:
|
||||
name: pageSize
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
||||
PageToken:
|
||||
name: pageToken
|
||||
in: query
|
||||
schema: { type: string }
|
||||
RuleId:
|
||||
name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
TemplateId:
|
||||
name: templateId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
IncidentId:
|
||||
name: incidentId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
|
||||
responses:
|
||||
Error:
|
||||
description: Standard error envelope
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ErrorEnvelope' }
|
||||
examples:
|
||||
validation:
|
||||
value:
|
||||
error:
|
||||
code: validation_failed
|
||||
message: "quietHours.windows[0].start must be HH:mm"
|
||||
traceId: "f62f3c2b9c8e4c53"
|
||||
|
||||
schemas:
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
traceId: { type: string }
|
||||
|
||||
NotifyRule:
|
||||
type: object
|
||||
required: [ruleId, tenantId, name, match, actions]
|
||||
properties:
|
||||
ruleId: { type: string }
|
||||
tenantId: { type: string }
|
||||
name: { type: string }
|
||||
description: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
match: { $ref: '#/components/schemas/RuleMatch' }
|
||||
actions:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/RuleAction' }
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
RuleMatch:
|
||||
type: object
|
||||
properties:
|
||||
eventKinds:
|
||||
type: array
|
||||
items: { type: string }
|
||||
minSeverity: { type: string, enum: [info, low, medium, high, critical] }
|
||||
verdicts:
|
||||
type: array
|
||||
items: { type: string }
|
||||
labels:
|
||||
type: array
|
||||
items: { type: string }
|
||||
kevOnly: { type: boolean }
|
||||
|
||||
RuleAction:
|
||||
type: object
|
||||
required: [actionId, channel]
|
||||
properties:
|
||||
actionId: { type: string }
|
||||
channel: { type: string }
|
||||
template: { type: string }
|
||||
digest: { type: string, description: "Digest window key e.g. instant|5m|15m|1h|1d" }
|
||||
throttle: { type: string, description: "ISO-8601 duration, e.g. PT5M" }
|
||||
locale: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
NotifyTemplate:
|
||||
type: object
|
||||
required: [templateId, tenantId, key, channelType, locale, body, renderMode, format]
|
||||
properties:
|
||||
templateId: { type: string }
|
||||
tenantId: { type: string }
|
||||
key: { type: string }
|
||||
channelType: { type: string, enum: [slack, teams, email, webhook, custom] }
|
||||
locale: { type: string, description: "BCP-47, lower-case" }
|
||||
renderMode: { type: string, enum: [Markdown, Html, AdaptiveCard, PlainText, Json] }
|
||||
format: { type: string, enum: [slack, teams, email, webhook, json] }
|
||||
description: { type: string }
|
||||
body: { type: string }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
Incident:
|
||||
type: object
|
||||
required: [incidentId, kind, severity, startedAt]
|
||||
properties:
|
||||
incidentId: { type: string }
|
||||
kind: { type: string, description: "outage|degradation|security|ops-drill" }
|
||||
severity: { type: string, enum: [minor, major, critical] }
|
||||
startedAt: { type: string, format: date-time }
|
||||
endedAt: { type: string, format: date-time }
|
||||
shortDescription: { type: string }
|
||||
description: { type: string }
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
PackApprovalEvent:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- issuedAt
|
||||
- kind
|
||||
- packId
|
||||
- decision
|
||||
- actor
|
||||
properties:
|
||||
eventId: { type: string, format: uuid }
|
||||
issuedAt: { type: string, format: date-time }
|
||||
kind:
|
||||
type: string
|
||||
enum: [pack.approval.granted, pack.approval.denied, pack.policy.override]
|
||||
packId: { type: string }
|
||||
policy:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
version: { type: string }
|
||||
decision:
|
||||
type: string
|
||||
enum: [approved, denied, overridden]
|
||||
actor: { type: string }
|
||||
resumeToken:
|
||||
type: string
|
||||
description: Opaque token for at-least-once resume.
|
||||
summary: { type: string }
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
QuietHours:
|
||||
type: object
|
||||
required: [quietHoursId, windows]
|
||||
properties:
|
||||
quietHoursId: { type: string }
|
||||
windows:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/QuietHoursWindow' }
|
||||
exemptions:
|
||||
type: array
|
||||
description: Event kinds that bypass quiet hours
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
eventKinds:
|
||||
type: array
|
||||
items: { type: string }
|
||||
reason: { type: string }
|
||||
|
||||
QuietHoursWindow:
|
||||
type: object
|
||||
required: [timezone, days, start, end]
|
||||
properties:
|
||||
timezone: { type: string, description: "IANA TZ, e.g., UTC" }
|
||||
days:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
|
||||
start: { type: string, description: "HH:mm" }
|
||||
end: { type: string, description: "HH:mm" }
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
public sealed class PackApprovalAckRequest
|
||||
{
|
||||
[Required]
|
||||
public string AckToken { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -118,6 +118,50 @@ app.MapPost("/api/v1/notify/pack-approvals", async (
|
||||
return Results.Accepted();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
HttpContext context,
|
||||
string packId,
|
||||
PackApprovalAckRequest request,
|
||||
INotifyLockRepository locks,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AckToken))
|
||||
{
|
||||
return Results.BadRequest(Error("ack_token_missing", "AckToken is required.", context));
|
||||
}
|
||||
|
||||
var lockKey = $"pack-approvals-ack|{tenantId}|{packId}|{request.AckToken}";
|
||||
var reserved = await locks.TryAcquireAsync(tenantId, lockKey, "pack-approvals-ack", TimeSpan.FromMinutes(10), context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!reserved)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = "pack-approvals-ack",
|
||||
Action = "pack.approval.acknowledged",
|
||||
EntityId = packId,
|
||||
EntityType = "pack-approval",
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context, OpenApiDocumentCache cache) =>
|
||||
{
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
|
||||
@@ -12,7 +12,9 @@ public sealed class OpenApiDocumentCache
|
||||
var path = Path.Combine(environment.ContentRootPath, "openapi", "notify-openapi.yaml");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new FileNotFoundException("OpenAPI document not found.", path);
|
||||
_document = string.Empty;
|
||||
_hash = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
_document = File.ReadAllText(path, Encoding.UTF8);
|
||||
|
||||
@@ -317,6 +317,75 @@ paths:
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/pack-approvals:
|
||||
post:
|
||||
summary: Ingest pack approval decision
|
||||
tags: [PackApprovals]
|
||||
operationId: ingestPackApproval
|
||||
security:
|
||||
- oauth2: [notify.operator]
|
||||
- hmac: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/PackApprovalEvent' }
|
||||
examples:
|
||||
approval-granted:
|
||||
value:
|
||||
eventId: "20e4e5fe-3d4a-4f57-9f9b-b1a1c1111111"
|
||||
issuedAt: "2025-11-17T16:00:00Z"
|
||||
kind: "pack.approval.granted"
|
||||
packId: "offline-kit-2025-11"
|
||||
policy:
|
||||
id: "policy-123"
|
||||
version: "v5"
|
||||
decision: "approved"
|
||||
actor: "task-runner"
|
||||
resumeToken: "rt-abc123"
|
||||
summary: "All required attestations verified."
|
||||
labels:
|
||||
environment: "prod"
|
||||
approver: "ops"
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted; durable write queued for processing.
|
||||
headers:
|
||||
X-Resume-After:
|
||||
description: Resume token echo or replacement
|
||||
schema: { type: string }
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
/api/v1/notify/pack-approvals/{packId}/ack:
|
||||
post:
|
||||
summary: Acknowledge a pack approval notification
|
||||
tags: [PackApprovals]
|
||||
operationId: ackPackApproval
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Tenant'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ackToken: { type: string }
|
||||
required: [ackToken]
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledged
|
||||
default:
|
||||
$ref: '#/components/responses/Error'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
oauth2:
|
||||
@@ -328,6 +397,10 @@ components:
|
||||
notify.viewer: Read-only Notifier access
|
||||
notify.operator: Manage rules/templates/incidents within tenant
|
||||
notify.admin: Tenant-scoped administration
|
||||
hmac:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Pre-shared HMAC token (air-gap friendly) referenced by secretRef.
|
||||
parameters:
|
||||
Tenant:
|
||||
name: X-StellaOps-Tenant
|
||||
@@ -335,6 +408,12 @@ components:
|
||||
required: true
|
||||
description: Tenant slug
|
||||
schema: { type: string }
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: Stable UUID to dedupe retries.
|
||||
schema: { type: string, format: uuid }
|
||||
PageSize:
|
||||
name: pageSize
|
||||
in: query
|
||||
@@ -468,6 +547,39 @@ components:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
PackApprovalEvent:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- issuedAt
|
||||
- kind
|
||||
- packId
|
||||
- decision
|
||||
- actor
|
||||
properties:
|
||||
eventId: { type: string, format: uuid }
|
||||
issuedAt: { type: string, format: date-time }
|
||||
kind:
|
||||
type: string
|
||||
enum: [pack.approval.granted, pack.approval.denied, pack.policy.override]
|
||||
packId: { type: string }
|
||||
policy:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
version: { type: string }
|
||||
decision:
|
||||
type: string
|
||||
enum: [approved, denied, overridden]
|
||||
actor: { type: string }
|
||||
resumeToken:
|
||||
type: string
|
||||
description: Opaque token for at-least-once resume.
|
||||
summary: { type: string }
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
|
||||
QuietHours:
|
||||
type: object
|
||||
required: [quietHoursId, windows]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"templateId": "tmpl-pack-approval-slack-en",
|
||||
"tenantId": "tenant-sample",
|
||||
"key": "pack.approval.granted",
|
||||
"channelType": "slack",
|
||||
"locale": "en-US",
|
||||
"renderMode": "Markdown",
|
||||
"format": "slack",
|
||||
"description": "Pack approval granted (Slack, English)",
|
||||
"body": "*Pack approval granted*\nPack: {{packId}}\nPolicy: {{policy.id}} ({{policy.version}})\nDecision: {{decision}}\nResume: {{resumeToken}}\nSummary: {{summary}}\nLabels: {{#each labels}}{{@key}}={{this}} {{/each}}",
|
||||
"metadata": {
|
||||
"redaction": "safe",
|
||||
"throttle": "PT5M"
|
||||
}
|
||||
},
|
||||
{
|
||||
"templateId": "tmpl-pack-approval-email-en",
|
||||
"tenantId": "tenant-sample",
|
||||
"key": "pack.approval.granted",
|
||||
"channelType": "email",
|
||||
"locale": "en-US",
|
||||
"renderMode": "Html",
|
||||
"format": "email",
|
||||
"description": "Pack approval granted (Email, English)",
|
||||
"body": "<h3>Pack approval granted</h3><p><strong>Pack:</strong> {{packId}}<br/><strong>Policy:</strong> {{policy.id}} ({{policy.version}})<br/><strong>Decision:</strong> {{decision}}<br/><strong>Resume:</strong> {{resumeToken}}<br/><strong>Summary:</strong> {{summary}}</p><p><strong>Labels:</strong> {{#each labels}}{{@key}}={{this}} {{/each}}</p>",
|
||||
"metadata": {
|
||||
"redaction": "safe",
|
||||
"throttle": "PT5M",
|
||||
"subject": "[Notify] Pack approval granted: {{packId}}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"routingPredicates": [
|
||||
{
|
||||
"name": "pack-approval-default",
|
||||
"match": {
|
||||
"eventKinds": ["pack.approval.granted", "pack.approval.denied", "pack.policy.override"],
|
||||
"labels": ["environment=prod"]
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"channel": "slack:sec-approvals",
|
||||
"template": "tmpl-pack-approval-slack-en",
|
||||
"digest": "instant"
|
||||
},
|
||||
{
|
||||
"channel": "email:ops-approvals",
|
||||
"template": "tmpl-pack-approval-email-en",
|
||||
"digest": "instant"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"redaction": {
|
||||
"allow": [
|
||||
"packId",
|
||||
"policy.id",
|
||||
"policy.version",
|
||||
"decision",
|
||||
"resumeToken",
|
||||
"summary",
|
||||
"labels.*"
|
||||
],
|
||||
"deny": [
|
||||
"secrets",
|
||||
"tokens"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
internal sealed record AdvisoryAiKnob(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("default_value")] decimal DefaultValue,
|
||||
[property: JsonPropertyName("min")] decimal Min,
|
||||
[property: JsonPropertyName("max")] decimal Max,
|
||||
[property: JsonPropertyName("step")] decimal Step,
|
||||
[property: JsonPropertyName("description")] string Description);
|
||||
|
||||
internal sealed record AdvisoryAiKnobsProfile(
|
||||
[property: JsonPropertyName("knobs")] IReadOnlyList<AdvisoryAiKnob> Knobs,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash);
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for Advisory AI knobs (POLICY-ENGINE-31-001).
|
||||
/// </summary>
|
||||
internal sealed class AdvisoryAiKnobsService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
private AdvisoryAiKnobsProfile _current;
|
||||
|
||||
public AdvisoryAiKnobsService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_current = BuildProfile(DefaultKnobs());
|
||||
}
|
||||
|
||||
public AdvisoryAiKnobsProfile Get() => _current;
|
||||
|
||||
public AdvisoryAiKnobsProfile Set(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var normalized = Normalize(knobs);
|
||||
var profile = BuildProfile(normalized);
|
||||
lock (_lock)
|
||||
{
|
||||
_current = profile;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private AdvisoryAiKnobsProfile BuildProfile(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(knobs, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
return new AdvisoryAiKnobsProfile(knobs, hash);
|
||||
}
|
||||
|
||||
private IReadOnlyList<AdvisoryAiKnob> Normalize(IReadOnlyList<AdvisoryAiKnob> knobs)
|
||||
{
|
||||
var normalized = knobs
|
||||
.Where(k => !string.IsNullOrWhiteSpace(k.Name))
|
||||
.Select(k => new AdvisoryAiKnob(
|
||||
Name: k.Name.Trim().ToLowerInvariant(),
|
||||
DefaultValue: k.DefaultValue,
|
||||
Min: k.Min,
|
||||
Max: k.Max,
|
||||
Step: k.Step <= 0 ? 0.001m : k.Step,
|
||||
Description: string.IsNullOrWhiteSpace(k.Description) ? string.Empty : k.Description.Trim()))
|
||||
.OrderBy(k => k.Name, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<AdvisoryAiKnob> DefaultKnobs() =>
|
||||
new[]
|
||||
{
|
||||
new AdvisoryAiKnob("ai_signal_weight", 1.0m, 0m, 2m, 0.01m, "Weight applied to AI signals"),
|
||||
new AdvisoryAiKnob("reachability_boost", 0.2m, 0m, 1m, 0.01m, "Boost when asset is reachable"),
|
||||
new AdvisoryAiKnob("time_decay_half_life_days", 30m, 1m, 365m, 1m, "Half-life for decay"),
|
||||
new AdvisoryAiKnob("evidence_freshness_threshold_hours", 72m, 1m, 720m, 1m, "Max evidence age")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
internal sealed record BatchContextRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextItem> Items,
|
||||
[property: JsonPropertyName("options")] BatchContextOptions Options);
|
||||
|
||||
internal sealed record BatchContextItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
|
||||
|
||||
internal sealed record BatchContextOptions(
|
||||
[property: JsonPropertyName("include_reachability")] bool IncludeReachability);
|
||||
|
||||
internal sealed record BatchContextResponse(
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("expires_at")] string ExpiresAt,
|
||||
[property: JsonPropertyName("knobs_version")] string KnobsVersion,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("items")] IReadOnlyList<BatchContextResolvedItem> Items);
|
||||
|
||||
internal sealed record BatchContextResolvedItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef);
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
/// <summary>
|
||||
/// Creates deterministic batch context responses for advisory AI (POLICY-ENGINE-31-002).
|
||||
/// </summary>
|
||||
internal sealed class BatchContextService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public BatchContextService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public BatchContextResponse Create(BatchContextRequest request)
|
||||
{
|
||||
if (request is null) throw new ArgumentNullException(nameof(request));
|
||||
if (request.Items is null || request.Items.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("items are required", nameof(request.Items));
|
||||
}
|
||||
|
||||
var sortedItems = request.Items
|
||||
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var status = request.Options?.IncludeReachability == true ? "pending-reachability" : "pending";
|
||||
var resolved = sortedItems
|
||||
.Select(i => new BatchContextResolvedItem(
|
||||
i.ComponentPurl,
|
||||
i.AdvisoryId,
|
||||
status,
|
||||
ComputeTraceRef(request.TenantId, i)))
|
||||
.ToList();
|
||||
|
||||
var expires = _timeProvider.GetUtcNow().AddHours(1).ToString("O");
|
||||
var contextId = ComputeContextId(request, sortedItems);
|
||||
|
||||
return new BatchContextResponse(
|
||||
contextId,
|
||||
ExpiresAt: expires,
|
||||
KnobsVersion: request.KnobsVersion,
|
||||
OverlayHash: request.OverlayHash,
|
||||
Items: resolved);
|
||||
}
|
||||
|
||||
private static string ComputeContextId(BatchContextRequest request, IReadOnlyList<BatchContextItem> sortedItems)
|
||||
{
|
||||
var canonical = new
|
||||
{
|
||||
tenant = request.TenantId,
|
||||
profile = request.PolicyProfileHash,
|
||||
knobs = request.KnobsVersion,
|
||||
overlay = request.OverlayHash,
|
||||
items = sortedItems.Select(i => new { i.ComponentPurl, i.AdvisoryId }).ToArray(),
|
||||
includeReachability = request.Options?.IncludeReachability ?? false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(canonical, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(json), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static string ComputeTraceRef(string tenant, BatchContextItem item)
|
||||
{
|
||||
var stable = $"{tenant}|{item.ComponentPurl}|{item.AdvisoryId}";
|
||||
Span<byte> hash = stackalloc byte[12];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class AdvisoryAiKnobsEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAdvisoryAiKnobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/policy/advisory-ai/knobs", GetAsync)
|
||||
.WithName("PolicyEngine.AdvisoryAI.Knobs.Get");
|
||||
|
||||
routes.MapPut("/policy/advisory-ai/knobs", PutAsync)
|
||||
.WithName("PolicyEngine.AdvisoryAI.Knobs.Put");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetAsync(AdvisoryAiKnobsService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PutAsync(
|
||||
[FromBody] IReadOnlyList<AdvisoryAiKnob> knobs,
|
||||
AdvisoryAiKnobsService service)
|
||||
{
|
||||
if (knobs is null || knobs.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "knobs are required" });
|
||||
}
|
||||
|
||||
var profile = service.Set(knobs);
|
||||
return Results.Json(profile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.BatchContext;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class BatchContextEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapBatchContext(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/batch/context", HandleAsync)
|
||||
.WithName("PolicyEngine.BatchContext.Create");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult HandleAsync(
|
||||
[FromBody] BatchContextRequest request,
|
||||
BatchContextService service)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = service.Create(request);
|
||||
return Results.Json(response);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class LedgerExportEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLedgerExport(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/ledger/export", BuildAsync)
|
||||
.WithName("PolicyEngine.Ledger.Export");
|
||||
|
||||
routes.MapGet("/policy/ledger/export/{exportId}", GetAsync)
|
||||
.WithName("PolicyEngine.Ledger.GetExport");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> BuildAsync(
|
||||
[FromBody] LedgerExportRequest request,
|
||||
LedgerExportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var export = await service.BuildAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(export);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string exportId,
|
||||
LedgerExportService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var export = await service.GetAsync(exportId, cancellationToken).ConfigureAwait(false);
|
||||
return export is null ? Results.NotFound() : Results.Json(export);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class OrchestratorJobEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOrchestratorJobs(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/orchestrator/jobs", SubmitAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Submit");
|
||||
|
||||
routes.MapPost("/policy/orchestrator/jobs/preview", PreviewAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Preview");
|
||||
|
||||
routes.MapGet("/policy/orchestrator/jobs/{jobId}", GetAsync)
|
||||
.WithName("PolicyEngine.Orchestrator.Jobs.Get");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SubmitAsync(
|
||||
[FromBody] OrchestratorJobRequest request,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.SubmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewAsync(
|
||||
[FromBody] OrchestratorJobRequest request,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var job = await service.PreviewAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(job);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string jobId,
|
||||
OrchestratorJobService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await service.GetAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return job is null ? Results.NotFound() : Results.Json(job);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
@@ -19,6 +20,7 @@ public static class PathScopeSimulationEndpoint
|
||||
private static async Task<IResult> HandleAsync(
|
||||
[FromBody] PathScopeSimulationRequest request,
|
||||
PathScopeSimulationService service,
|
||||
PathScopeSimulationBridgeService bridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -31,6 +33,19 @@ public static class PathScopeSimulationEndpoint
|
||||
responseBuilder.AppendLine(line);
|
||||
}
|
||||
|
||||
// Emit change event stub when run in what-if mode.
|
||||
if (request.Options.Deterministic && request.Options.IncludeTrace)
|
||||
{
|
||||
var bridgeRequest = new PathScopeSimulationBridgeRequest(
|
||||
Tenant: request.Tenant,
|
||||
Rules: Array.Empty<string>(),
|
||||
Overlays: null,
|
||||
Paths: new[] { request },
|
||||
Mode: "preview",
|
||||
Seed: null);
|
||||
await bridge.SimulateAsync(bridgeRequest, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Results.Text(responseBuilder.ToString(), "application/x-ndjson", Encoding.UTF8);
|
||||
}
|
||||
catch (PathScopeSimulationException ex)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class PolicyWorkerEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPolicyWorker(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/worker/run", RunAsync)
|
||||
.WithName("PolicyEngine.Worker.Run");
|
||||
|
||||
routes.MapGet("/policy/worker/jobs/{jobId}", GetResultAsync)
|
||||
.WithName("PolicyEngine.Worker.GetResult");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> RunAsync(
|
||||
[FromBody] WorkerRunRequest request,
|
||||
PolicyWorkerService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.ExecuteAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return Results.NotFound(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetResultAsync(
|
||||
[FromRoute] string jobId,
|
||||
IWorkerResultStore store,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await store.GetByJobIdAsync(jobId, cancellationToken).ConfigureAwait(false);
|
||||
return result is null ? Results.NotFound() : Results.Json(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class SnapshotEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSnapshots(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/snapshots", CreateAsync)
|
||||
.WithName("PolicyEngine.Snapshots.Create");
|
||||
|
||||
routes.MapGet("/policy/snapshots", ListAsync)
|
||||
.WithName("PolicyEngine.Snapshots.List");
|
||||
|
||||
routes.MapGet("/policy/snapshots/{snapshotId}", GetAsync)
|
||||
.WithName("PolicyEngine.Snapshots.Get");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateAsync(
|
||||
[FromBody] SnapshotRequest request,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await service.CreateAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(snapshot);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListAsync(
|
||||
[FromQuery(Name = "tenant_id")] string? tenantId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (items, cursor) = await service.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { items, next_cursor = cursor });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAsync(
|
||||
[FromRoute] string snapshotId,
|
||||
SnapshotService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var snapshot = await service.GetAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
return snapshot is null ? Results.NotFound() : Results.Json(snapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class TrustWeightingEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTrustWeighting(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/policy/trust-weighting", GetAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Get");
|
||||
|
||||
routes.MapPut("/policy/trust-weighting", PutAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Put");
|
||||
|
||||
routes.MapGet("/policy/trust-weighting/preview", PreviewAsync)
|
||||
.WithName("PolicyEngine.TrustWeighting.Preview");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static IResult GetAsync(TrustWeightingService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PutAsync(
|
||||
[FromBody] IReadOnlyList<TrustWeightingEntry> weights,
|
||||
TrustWeightingService service)
|
||||
{
|
||||
if (weights is null || weights.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { message = "weights are required" });
|
||||
}
|
||||
|
||||
var profile = service.Set(weights);
|
||||
return Results.Json(profile);
|
||||
}
|
||||
|
||||
private static IResult PreviewAsync(
|
||||
[FromQuery(Name = "overlay_hash")] string? overlayHash,
|
||||
TrustWeightingService service)
|
||||
{
|
||||
var profile = service.Get();
|
||||
var preview = new
|
||||
{
|
||||
weights = profile.Weights,
|
||||
profile_hash = profile.ProfileHash,
|
||||
overlay_hash = overlayHash,
|
||||
mode = "preview"
|
||||
};
|
||||
|
||||
return Results.Json(preview);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
public static class ViolationEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapViolations(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapPost("/policy/violations/events", EmitEventsAsync)
|
||||
.WithName("PolicyEngine.Violations.Events");
|
||||
|
||||
routes.MapPost("/policy/violations/severity", FuseAsync)
|
||||
.WithName("PolicyEngine.Violations.Severity");
|
||||
|
||||
routes.MapPost("/policy/violations/conflicts", ConflictsAsync)
|
||||
.WithName("PolicyEngine.Violations.Conflicts");
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
private static async Task<IResult> EmitEventsAsync(
|
||||
[FromBody] ViolationEventRequest request,
|
||||
ViolationEventService service,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await service.EmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { events });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> FuseAsync(
|
||||
[FromBody] ViolationEventRequest request,
|
||||
ViolationEventService eventService,
|
||||
SeverityFusionService fusionService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventService.EmitAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { fused });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ConflictsAsync(
|
||||
[FromBody] ConflictRequest request,
|
||||
ViolationEventService eventService,
|
||||
SeverityFusionService fusionService,
|
||||
ConflictHandlingService conflictService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await eventService.EmitAsync(new ViolationEventRequest(request.SnapshotId), cancellationToken).ConfigureAwait(false);
|
||||
var fused = await fusionService.FuseAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
var conflicts = await conflictService.ComputeAsync(request.SnapshotId, fused, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Json(new { conflicts });
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException)
|
||||
{
|
||||
return Results.BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerExportService.cs
Normal file
103
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerExportService.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic NDJSON ledger exports from worker results (POLICY-ENGINE-34-101).
|
||||
/// </summary>
|
||||
internal sealed class LedgerExportService
|
||||
{
|
||||
private const string SchemaVersion = "policy-ledger-export-v1";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IWorkerResultStore _results;
|
||||
private readonly ILedgerExportStore _store;
|
||||
|
||||
public LedgerExportService(
|
||||
TimeProvider timeProvider,
|
||||
IOrchestratorJobStore jobs,
|
||||
IWorkerResultStore results,
|
||||
ILedgerExportStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<LedgerExport> BuildAsync(LedgerExportRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var jobs = await _jobs.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var completed = jobs.Where(j => string.Equals(j.Status, "completed", StringComparison.Ordinal)).ToList();
|
||||
|
||||
var records = new List<LedgerExportRecord>();
|
||||
|
||||
foreach (var job in completed)
|
||||
{
|
||||
var result = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (result is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var item in result.Results)
|
||||
{
|
||||
records.Add(new LedgerExportRecord(
|
||||
TenantId: job.TenantId,
|
||||
JobId: job.JobId,
|
||||
ContextId: job.ContextId,
|
||||
ComponentPurl: item.ComponentPurl,
|
||||
AdvisoryId: item.AdvisoryId,
|
||||
Status: item.Status,
|
||||
TraceRef: item.TraceRef,
|
||||
OccurredAt: result.CompletedAt.ToString("O")));
|
||||
}
|
||||
}
|
||||
|
||||
var ordered = records
|
||||
.OrderBy(r => r.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.JobId, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var exportId = StableIdGenerator.CreateUlid($"{request.TenantId}|{generatedAt}|{ordered.Count}");
|
||||
|
||||
var recordLines = ordered.Select(r => JsonSerializer.Serialize(r, SerializerOptions)).ToList();
|
||||
var sha = StableIdGenerator.Sha256Hex(string.Join('\n', recordLines));
|
||||
|
||||
var manifest = new LedgerExportManifest(
|
||||
ExportId: exportId,
|
||||
SchemaVersion: SchemaVersion,
|
||||
GeneratedAt: generatedAt,
|
||||
RecordCount: ordered.Count,
|
||||
Sha256: sha);
|
||||
|
||||
var lines = new List<string>(recordLines.Count + 1)
|
||||
{
|
||||
JsonSerializer.Serialize(manifest, SerializerOptions)
|
||||
};
|
||||
lines.AddRange(recordLines);
|
||||
|
||||
var export = new LedgerExport(manifest, ordered, lines);
|
||||
await _store.SaveAsync(export, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(exportId, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.ListAsync(tenantId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
internal interface ILedgerExportStore
|
||||
{
|
||||
Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default);
|
||||
Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLedgerExportStore : ILedgerExportStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, LedgerExport> _exports = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(LedgerExport export, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(export);
|
||||
_exports[export.Manifest.ExportId] = export;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<LedgerExport?> GetAsync(string exportId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_exports.TryGetValue(exportId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<LedgerExport>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<LedgerExport> exports = _exports.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
exports = exports.Where(x => x.Records.Any(r => string.Equals(r.TenantId, tenantId, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
var ordered = exports
|
||||
.OrderBy(e => e.Manifest.GeneratedAt, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Manifest.ExportId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<LedgerExport>>(ordered);
|
||||
}
|
||||
}
|
||||
28
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerModels.cs
Normal file
28
src/Policy/StellaOps.Policy.Engine/Ledger/LedgerModels.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
internal sealed record LedgerExportRecord(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("occurred_at")] string OccurredAt);
|
||||
|
||||
internal sealed record LedgerExportManifest(
|
||||
[property: JsonPropertyName("export_id")] string ExportId,
|
||||
[property: JsonPropertyName("schema_version")] string SchemaVersion,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("record_count")] int RecordCount,
|
||||
[property: JsonPropertyName("sha256")] string Sha256);
|
||||
|
||||
internal sealed record LedgerExport(
|
||||
LedgerExportManifest Manifest,
|
||||
IReadOnlyList<LedgerExportRecord> Records,
|
||||
IReadOnlyList<string> Lines);
|
||||
|
||||
internal sealed record LedgerExportRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId);
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed record OrchestratorJobItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId);
|
||||
|
||||
internal sealed record OrchestratorJobCallbacks(
|
||||
[property: JsonPropertyName("sse")] string? Sse,
|
||||
[property: JsonPropertyName("nats")] string? Nats);
|
||||
|
||||
internal sealed record OrchestratorJobRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
|
||||
[property: JsonPropertyName("priority")] string Priority = "normal",
|
||||
[property: JsonPropertyName("trace_ref")] string? TraceRef = null,
|
||||
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks = null,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset? RequestedAt = null);
|
||||
|
||||
internal sealed record OrchestratorJob(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("context_id")] string ContextId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("requested_at")] DateTimeOffset RequestedAt,
|
||||
[property: JsonPropertyName("priority")] string Priority,
|
||||
[property: JsonPropertyName("batch_items")] IReadOnlyList<OrchestratorJobItem> BatchItems,
|
||||
[property: JsonPropertyName("callbacks")] OrchestratorJobCallbacks? Callbacks,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("determinism_hash")] string DeterminismHash,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset? CompletedAt = null,
|
||||
[property: JsonPropertyName("result_hash")] string? ResultHash = null);
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed class OrchestratorJobService
|
||||
{
|
||||
private static readonly string[] AllowedPriorities = { "normal", "high", "emergency" };
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _store;
|
||||
|
||||
public OrchestratorJobService(TimeProvider timeProvider, IOrchestratorJobStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<OrchestratorJob> SubmitAsync(
|
||||
OrchestratorJobRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = BuildJob(request);
|
||||
await _store.SaveAsync(job, cancellationToken).ConfigureAwait(false);
|
||||
return job;
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob> PreviewAsync(OrchestratorJobRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var job = BuildJob(request, preview: true);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(jobId, cancellationToken);
|
||||
}
|
||||
|
||||
private OrchestratorJob BuildJob(OrchestratorJobRequest request, bool preview = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (request.BatchItems is null || request.BatchItems.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("batch_items are required", nameof(request));
|
||||
}
|
||||
|
||||
var normalizedPriority = NormalizePriority(request.Priority);
|
||||
var requestedAt = request.RequestedAt ?? _timeProvider.GetUtcNow();
|
||||
|
||||
var orderedItems = request.BatchItems
|
||||
.OrderBy(i => i.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(i => i.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var seed = $"{request.TenantId}|{request.ContextId}|{requestedAt:O}";
|
||||
var jobId = StableIdGenerator.CreateUlid(seed);
|
||||
var traceRef = request.TraceRef ?? StableIdGenerator.Sha256Hex($"{jobId}|trace");
|
||||
|
||||
var determinismHash = StableIdGenerator.Sha256Hex(BuildDeterminismSeed(request, orderedItems, requestedAt, normalizedPriority));
|
||||
|
||||
var status = preview ? "preview" : "queued";
|
||||
|
||||
return new OrchestratorJob(
|
||||
JobId: jobId,
|
||||
TenantId: request.TenantId,
|
||||
ContextId: request.ContextId,
|
||||
PolicyProfileHash: request.PolicyProfileHash,
|
||||
RequestedAt: requestedAt,
|
||||
Priority: normalizedPriority,
|
||||
BatchItems: orderedItems,
|
||||
Callbacks: request.Callbacks,
|
||||
TraceRef: traceRef,
|
||||
Status: status,
|
||||
DeterminismHash: determinismHash);
|
||||
}
|
||||
|
||||
private static string NormalizePriority(string? value)
|
||||
{
|
||||
var normalized = (value ?? "normal").Trim().ToLowerInvariant();
|
||||
if (!AllowedPriorities.Contains(normalized))
|
||||
{
|
||||
normalized = "normal";
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string BuildDeterminismSeed(
|
||||
OrchestratorJobRequest request,
|
||||
IReadOnlyList<OrchestratorJobItem> items,
|
||||
DateTimeOffset requestedAt,
|
||||
string priority)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(request.TenantId).Append('|')
|
||||
.Append(request.ContextId).Append('|')
|
||||
.Append(request.PolicyProfileHash).Append('|')
|
||||
.Append(priority).Append('|')
|
||||
.Append(requestedAt.ToString("O"));
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
builder.Append('|').Append(item.ComponentPurl).Append('|').Append(item.AdvisoryId);
|
||||
}
|
||||
|
||||
if (request.Callbacks is not null)
|
||||
{
|
||||
builder.Append('|').Append(request.Callbacks.Sse).Append('|').Append(request.Callbacks.Nats);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal interface IOrchestratorJobStore
|
||||
{
|
||||
Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default);
|
||||
Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryOrchestratorJobStore : IOrchestratorJobStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, OrchestratorJob> _jobs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(OrchestratorJob job, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
_jobs[job.JobId] = job;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<OrchestratorJob?> GetAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_jobs.TryGetValue(jobId, out var job);
|
||||
return Task.FromResult(job);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OrchestratorJob>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<OrchestratorJob> items = _jobs.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
items = items.Where(j => string.Equals(j.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = items
|
||||
.OrderBy(j => j.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(j => j.RequestedAt)
|
||||
.ThenBy(j => j.JobId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<OrchestratorJob>>(ordered);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string jobId, Func<OrchestratorJob, OrchestratorJob> update, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(update);
|
||||
|
||||
_jobs.AddOrUpdate(
|
||||
jobId,
|
||||
_ => throw new KeyNotFoundException($"Job {jobId} not found"),
|
||||
(_, existing) => update(existing));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal sealed record WorkerResultItem(
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef);
|
||||
|
||||
internal sealed record WorkerRunResult(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("worker_id")] string WorkerId,
|
||||
[property: JsonPropertyName("started_at")] DateTimeOffset StartedAt,
|
||||
[property: JsonPropertyName("completed_at")] DateTimeOffset CompletedAt,
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<WorkerResultItem> Results,
|
||||
[property: JsonPropertyName("result_hash")] string ResultHash);
|
||||
|
||||
internal sealed record WorkerRunRequest(
|
||||
[property: JsonPropertyName("job_id")] string JobId,
|
||||
[property: JsonPropertyName("worker_id")] string? WorkerId = null);
|
||||
@@ -0,0 +1,107 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic worker stub for POLICY-ENGINE-33-101. Consumes orchestrator jobs and
|
||||
/// produces stable result hashes so retries can short-circuit.
|
||||
/// </summary>
|
||||
internal sealed class PolicyWorkerService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IWorkerResultStore _results;
|
||||
|
||||
public PolicyWorkerService(
|
||||
TimeProvider timeProvider,
|
||||
IOrchestratorJobStore jobs,
|
||||
IWorkerResultStore results)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_results = results ?? throw new ArgumentNullException(nameof(results));
|
||||
}
|
||||
|
||||
public async Task<WorkerRunResult> ExecuteAsync(
|
||||
WorkerRunRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var job = await _jobs.GetAsync(request.JobId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Job {request.JobId} not found");
|
||||
|
||||
var existing = await _results.GetByJobIdAsync(job.JobId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var workerId = string.IsNullOrWhiteSpace(request.WorkerId) ? "worker-stub" : request.WorkerId;
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
var results = BuildResults(job);
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var resultHash = StableIdGenerator.Sha256Hex(BuildSeed(job.JobId, results));
|
||||
|
||||
var runResult = new WorkerRunResult(
|
||||
JobId: job.JobId,
|
||||
WorkerId: workerId,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
Results: results,
|
||||
ResultHash: resultHash);
|
||||
|
||||
await _results.SaveAsync(runResult, cancellationToken).ConfigureAwait(false);
|
||||
await _jobs.UpdateAsync(job.JobId, j => j with { Status = "completed", CompletedAt = completedAt, ResultHash = resultHash }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return runResult;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WorkerResultItem> BuildResults(OrchestratorJob job)
|
||||
{
|
||||
var builder = new List<WorkerResultItem>(job.BatchItems.Count);
|
||||
|
||||
foreach (var item in job.BatchItems)
|
||||
{
|
||||
var hash = BuildItemHash(job.JobId, item);
|
||||
var status = (hash % 3) switch
|
||||
{
|
||||
0 => "violation",
|
||||
1 => "warn",
|
||||
_ => "ok"
|
||||
};
|
||||
|
||||
var traceRef = StableIdGenerator.Sha256Hex($"{job.JobId}|{item.ComponentPurl}|{item.AdvisoryId}");
|
||||
builder.Add(new WorkerResultItem(item.ComponentPurl, item.AdvisoryId, status, traceRef));
|
||||
}
|
||||
|
||||
return builder
|
||||
.OrderBy(r => r.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(r => r.AdvisoryId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int BuildItemHash(string jobId, OrchestratorJobItem item)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes($"{jobId}|{item.ComponentPurl}|{item.AdvisoryId}"), hash);
|
||||
return BitConverter.ToInt32(hash[..4]);
|
||||
}
|
||||
|
||||
private static string BuildSeed(string jobId, IReadOnlyList<WorkerResultItem> results)
|
||||
{
|
||||
var sb = new StringBuilder(jobId);
|
||||
foreach (var result in results)
|
||||
{
|
||||
sb.Append('|').Append(result.ComponentPurl)
|
||||
.Append('|').Append(result.AdvisoryId)
|
||||
.Append('|').Append(result.Status);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal static class StableIdGenerator
|
||||
{
|
||||
private const string Base32Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
public static string CreateUlid(string seed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(seed))
|
||||
{
|
||||
throw new ArgumentException("Seed is required", nameof(seed));
|
||||
}
|
||||
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(seed), hash);
|
||||
|
||||
Span<char> buffer = stackalloc char[26];
|
||||
EncodeBase32(hash[..16], buffer);
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static string Sha256Hex(string value)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(value), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
private static void EncodeBase32(ReadOnlySpan<byte> input, Span<char> output)
|
||||
{
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
int index = 0;
|
||||
|
||||
foreach (var b in input)
|
||||
{
|
||||
buffer = (buffer << 8) | b;
|
||||
bitsLeft += 8;
|
||||
|
||||
while (bitsLeft >= 5 && index < output.Length)
|
||||
{
|
||||
var value = (buffer >> (bitsLeft - 5)) & 31;
|
||||
output[index++] = Base32Alphabet[value];
|
||||
bitsLeft -= 5;
|
||||
}
|
||||
}
|
||||
|
||||
if (index < output.Length)
|
||||
{
|
||||
output[index++] = Base32Alphabet[(buffer << (5 - bitsLeft)) & 31];
|
||||
}
|
||||
|
||||
while (index < output.Length)
|
||||
{
|
||||
output[index++] = Base32Alphabet[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
internal interface IWorkerResultStore
|
||||
{
|
||||
Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default);
|
||||
Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryWorkerResultStore : IWorkerResultStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, WorkerRunResult> _results = new(StringComparer.Ordinal);
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
|
||||
public InMemoryWorkerResultStore(IOrchestratorJobStore jobs)
|
||||
{
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
}
|
||||
|
||||
public Task SaveAsync(WorkerRunResult result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
_results[result.JobId] = result;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<WorkerRunResult?> GetByJobIdAsync(string jobId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_results.TryGetValue(jobId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WorkerRunResult>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return _results.Values.OrderBy(r => r.JobId, StringComparer.Ordinal).ToList();
|
||||
}
|
||||
|
||||
var jobs = await _jobs.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var jobIds = jobs.Select(j => j.JobId).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var filtered = _results.Values
|
||||
.Where(r => jobIds.Contains(r.JobId))
|
||||
.OrderBy(r => r.JobId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
@@ -165,7 +165,7 @@ internal sealed class PathScopeSimulationBridgeService
|
||||
private static string BuildCorrelationId(PathScopeSimulationBridgeRequest request)
|
||||
{
|
||||
var stable = $"{request.Tenant}|{request.Mode}|{request.Seed ?? DefaultSeed}";
|
||||
Span<byte> hash = stackalloc byte[16];
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stable), hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@ using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.Client;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Hosting;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
using StellaOps.Policy.Engine.Compilation;
|
||||
using StellaOps.Policy.Engine.Endpoints;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Engine.Workers;
|
||||
using StellaOps.Policy.Engine.Streaming;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var policyEngineConfigFiles = new[]
|
||||
@@ -115,12 +116,27 @@ builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayProjectionS
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.IOverlayEventSink, StellaOps.Policy.Engine.Overlay.LoggingOverlayEventSink>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.OverlayChangeEventPublisher>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Overlay.PathScopeSimulationBridgeService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.TrustWeighting.TrustWeightingService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.AdvisoryAI.AdvisoryAiKnobsService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.BatchContext.BatchContextService>();
|
||||
builder.Services.AddSingleton<IPolicyPackRepository, InMemoryPolicyPackRepository>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddSingleton<IOrchestratorJobStore, InMemoryOrchestratorJobStore>();
|
||||
builder.Services.AddSingleton<OrchestratorJobService>();
|
||||
builder.Services.AddSingleton<IWorkerResultStore, InMemoryWorkerResultStore>();
|
||||
builder.Services.AddSingleton<PolicyWorkerService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.ILedgerExportStore, StellaOps.Policy.Engine.Ledger.InMemoryLedgerExportStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Ledger.LedgerExportService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.ISnapshotStore, StellaOps.Policy.Engine.Snapshots.InMemorySnapshotStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Snapshots.SnapshotService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.IViolationEventStore, StellaOps.Policy.Engine.Violations.InMemoryViolationEventStore>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ViolationEventService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.SeverityFusionService>();
|
||||
builder.Services.AddSingleton<StellaOps.Policy.Engine.Violations.ConflictHandlingService>();
|
||||
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
builder.Services.AddAuthentication();
|
||||
builder.Services.AddAuthorization();
|
||||
@@ -164,5 +180,13 @@ app.MapPolicyCompilation();
|
||||
app.MapPolicyPacks();
|
||||
app.MapPathScopeSimulation();
|
||||
app.MapOverlaySimulation();
|
||||
app.MapTrustWeighting();
|
||||
app.MapAdvisoryAiKnobs();
|
||||
app.MapBatchContext();
|
||||
app.MapOrchestratorJobs();
|
||||
app.MapPolicyWorker();
|
||||
app.MapLedgerExport();
|
||||
app.MapSnapshots();
|
||||
app.MapViolations();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -139,7 +139,7 @@ internal sealed partial class PolicyEvaluationService
|
||||
|
||||
private static string ComputeCorrelationId(string stableKey)
|
||||
{
|
||||
Span<byte> hashBytes = stackalloc byte[16];
|
||||
Span<byte> hashBytes = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(stableKey), hashBytes);
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
internal sealed record SnapshotSummary(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts);
|
||||
|
||||
internal sealed record SnapshotDetail(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("ledger_export_id")] string LedgerExportId,
|
||||
[property: JsonPropertyName("generated_at")] string GeneratedAt,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash,
|
||||
[property: JsonPropertyName("status_counts")] IReadOnlyDictionary<string, int> StatusCounts,
|
||||
[property: JsonPropertyName("records")] IReadOnlyList<LedgerExportRecord> Records);
|
||||
|
||||
internal sealed record SnapshotRequest(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("overlay_hash")] string OverlayHash);
|
||||
@@ -0,0 +1,76 @@
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot API stub (POLICY-ENGINE-35-201) built on ledger exports.
|
||||
/// </summary>
|
||||
internal sealed class SnapshotService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly LedgerExportService _ledger;
|
||||
private readonly ISnapshotStore _store;
|
||||
|
||||
public SnapshotService(
|
||||
TimeProvider timeProvider,
|
||||
LedgerExportService ledger,
|
||||
ISnapshotStore store)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_ledger = ledger ?? throw new ArgumentNullException(nameof(ledger));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<SnapshotDetail> CreateAsync(
|
||||
SnapshotRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var exports = await _ledger.ListAsync(request.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
var export = exports.LastOrDefault();
|
||||
if (export is null)
|
||||
{
|
||||
export = await _ledger.BuildAsync(new LedgerExportRequest(request.TenantId), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var statusCounts = export.Records
|
||||
.GroupBy(r => r.Status)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
|
||||
var generatedAt = _timeProvider.GetUtcNow().ToString("O");
|
||||
var snapshotId = StableIdGenerator.CreateUlid($"{export.Manifest.ExportId}|{request.OverlayHash}");
|
||||
|
||||
var snapshot = new SnapshotDetail(
|
||||
SnapshotId: snapshotId,
|
||||
TenantId: request.TenantId,
|
||||
LedgerExportId: export.Manifest.ExportId,
|
||||
GeneratedAt: generatedAt,
|
||||
OverlayHash: request.OverlayHash,
|
||||
StatusCounts: statusCounts,
|
||||
Records: export.Records);
|
||||
|
||||
await _store.SaveAsync(snapshot, cancellationToken).ConfigureAwait(false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _store.GetAsync(snapshotId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<SnapshotSummary> Items, string? NextCursor)> ListAsync(
|
||||
string? tenantId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshots = await _store.ListAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var summaries = snapshots
|
||||
.OrderByDescending(s => s.GeneratedAt, StringComparer.Ordinal)
|
||||
.Select(s => new SnapshotSummary(s.SnapshotId, s.TenantId, s.LedgerExportId, s.GeneratedAt, s.StatusCounts))
|
||||
.ToList();
|
||||
|
||||
return (summaries, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
internal interface ISnapshotStore
|
||||
{
|
||||
Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default);
|
||||
Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemorySnapshotStore : ISnapshotStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SnapshotDetail> _snapshots = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveAsync(SnapshotDetail snapshot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
_snapshots[snapshot.SnapshotId] = snapshot;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<SnapshotDetail?> GetAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_snapshots.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SnapshotDetail>> ListAsync(string? tenantId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
IEnumerable<SnapshotDetail> items = _snapshots.Values;
|
||||
if (!string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
items = items.Where(s => string.Equals(s.TenantId, tenantId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
var ordered = items
|
||||
.OrderBy(s => s.GeneratedAt, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SnapshotId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SnapshotDetail>>(ordered);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,11 @@ internal sealed class PathScopeSimulationService
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("subject.purl or subject.cpe is required"));
|
||||
}
|
||||
|
||||
if (!string.Equals(request.Options.Sort, "path,finding,verdict", StringComparison.Ordinal))
|
||||
{
|
||||
throw new PathScopeSimulationException(PathScopeSimulationError.Schema("options.sort must be 'path,finding,verdict'"));
|
||||
}
|
||||
|
||||
foreach (var target in request.Targets)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target.FilePath))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
internal sealed record TrustWeightingEntry(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("justification")] string? Justification,
|
||||
[property: JsonPropertyName("updated_at")] string UpdatedAt);
|
||||
|
||||
internal sealed record TrustWeightingProfile(
|
||||
[property: JsonPropertyName("weights")] IReadOnlyList<TrustWeightingEntry> Weights,
|
||||
[property: JsonPropertyName("profile_hash")] string ProfileHash);
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory trust weighting profile store (stub for POLICY-ENGINE-30-101).
|
||||
/// Deterministic ordering and hashing.
|
||||
/// </summary>
|
||||
internal sealed class TrustWeightingService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
private TrustWeightingProfile _current;
|
||||
|
||||
public TrustWeightingService(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_current = BuildProfile(DefaultWeights());
|
||||
}
|
||||
|
||||
public TrustWeightingProfile Get() => _current;
|
||||
|
||||
public TrustWeightingProfile Set(IReadOnlyList<TrustWeightingEntry> entries)
|
||||
{
|
||||
var normalized = Normalize(entries);
|
||||
var profile = BuildProfile(normalized);
|
||||
lock (_lock)
|
||||
{
|
||||
_current = profile;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private TrustWeightingProfile BuildProfile(IReadOnlyList<TrustWeightingEntry> weights)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(weights, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
return new TrustWeightingProfile(weights, hash);
|
||||
}
|
||||
|
||||
private IReadOnlyList<TrustWeightingEntry> Normalize(IReadOnlyList<TrustWeightingEntry> entries)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow().ToString("O");
|
||||
|
||||
var normalized = entries
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Source))
|
||||
.Select(e => new TrustWeightingEntry(
|
||||
Source: e.Source.Trim().ToLowerInvariant(),
|
||||
Weight: Math.Round(e.Weight, 3, MidpointRounding.ToZero),
|
||||
Justification: string.IsNullOrWhiteSpace(e.Justification) ? null : e.Justification.Trim(),
|
||||
UpdatedAt: string.IsNullOrWhiteSpace(e.UpdatedAt) ? now : e.UpdatedAt))
|
||||
.OrderBy(e => e.Source, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TrustWeightingEntry> DefaultWeights()
|
||||
{
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O");
|
||||
return new[]
|
||||
{
|
||||
new TrustWeightingEntry("cartographer", 1.000m, null, now),
|
||||
new TrustWeightingEntry("concelier", 1.000m, null, now),
|
||||
new TrustWeightingEntry("scanner", 1.000m, null, now),
|
||||
new TrustWeightingEntry("advisory_ai", 1.000m, null, now)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Conflict detection over fused severities (POLICY-ENGINE-40-002).
|
||||
/// </summary>
|
||||
internal sealed class ConflictHandlingService
|
||||
{
|
||||
private readonly IViolationEventStore _store;
|
||||
|
||||
public ConflictHandlingService(IViolationEventStore store)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ConflictRecord>> ComputeAsync(string snapshotId, IReadOnlyList<SeverityFusionResult>? fused = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
|
||||
}
|
||||
|
||||
var source = fused ?? await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
var conflicts = new List<ConflictRecord>();
|
||||
|
||||
var grouped = source
|
||||
.GroupBy(r => (r.ComponentPurl, r.AdvisoryId, r.TenantId))
|
||||
.Where(g => g.Select(x => x.SeverityFused).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1);
|
||||
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
conflicts.Add(new ConflictRecord(
|
||||
TenantId: group.Key.TenantId,
|
||||
ComponentPurl: group.Key.ComponentPurl,
|
||||
AdvisoryId: group.Key.AdvisoryId,
|
||||
Conflicts: group.ToList(),
|
||||
ResolvedStatus: null));
|
||||
}
|
||||
|
||||
await _store.SaveConflictsAsync(snapshotId, conflicts, cancellationToken).ConfigureAwait(false);
|
||||
return conflicts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic severity fusion (POLICY-ENGINE-40-001).
|
||||
/// </summary>
|
||||
internal sealed class SeverityFusionService
|
||||
{
|
||||
private readonly IViolationEventStore _store;
|
||||
private readonly TrustWeightingService _weights;
|
||||
|
||||
public SeverityFusionService(IViolationEventStore store, TrustWeightingService weights)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_weights = weights ?? throw new ArgumentNullException(nameof(weights));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SeverityFusionResult>> FuseAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(snapshotId))
|
||||
{
|
||||
throw new ArgumentException("snapshot_id is required", nameof(snapshotId));
|
||||
}
|
||||
|
||||
var existing = await _store.GetFusionAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var events = await _store.GetEventsAsync(snapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (events.Count == 0)
|
||||
{
|
||||
return Array.Empty<SeverityFusionResult>();
|
||||
}
|
||||
|
||||
var weights = _weights.Get();
|
||||
var defaultWeight = weights.Weights.FirstOrDefault(w => string.Equals(w.Source, "policy-engine", StringComparison.OrdinalIgnoreCase))?.Weight ?? 1.0m;
|
||||
|
||||
var results = new List<SeverityFusionResult>(events.Count);
|
||||
foreach (var ev in events.OrderBy(e => e.ComponentPurl, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.AdvisoryId, StringComparer.Ordinal))
|
||||
{
|
||||
var baseScore = SeverityToScore(ev.Severity);
|
||||
var weightedScore = Math.Round(baseScore * defaultWeight, 3, MidpointRounding.ToZero);
|
||||
var fusedSeverity = ScoreToLabel(weightedScore);
|
||||
|
||||
var sources = new List<SeveritySource>
|
||||
{
|
||||
new("policy-engine", defaultWeight, ev.Severity, weightedScore)
|
||||
};
|
||||
|
||||
var reasons = new List<string> { "weights-applied", "deterministic-fusion" };
|
||||
|
||||
results.Add(new SeverityFusionResult(
|
||||
TenantId: ev.TenantId,
|
||||
SnapshotId: ev.SnapshotId,
|
||||
ComponentPurl: ev.ComponentPurl,
|
||||
AdvisoryId: ev.AdvisoryId,
|
||||
SeverityFused: fusedSeverity,
|
||||
Score: weightedScore,
|
||||
Sources: sources,
|
||||
ReasonCodes: reasons));
|
||||
}
|
||||
|
||||
await _store.SaveFusionAsync(snapshotId, results, cancellationToken).ConfigureAwait(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static decimal SeverityToScore(string severity) => severity.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" => 1.0m,
|
||||
"high" => 0.9m,
|
||||
"medium" => 0.6m,
|
||||
"warn" => 0.5m,
|
||||
_ => 0.3m
|
||||
};
|
||||
|
||||
private static string ScoreToLabel(decimal score) => score switch
|
||||
{
|
||||
>= 0.9m => "critical",
|
||||
>= 0.75m => "high",
|
||||
>= 0.5m => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
/// <summary>
|
||||
/// Emits violation events from snapshots (POLICY-ENGINE-38-201).
|
||||
/// </summary>
|
||||
internal sealed class ViolationEventService
|
||||
{
|
||||
private readonly ISnapshotStore _snapshots;
|
||||
private readonly IOrchestratorJobStore _jobs;
|
||||
private readonly IViolationEventStore _store;
|
||||
|
||||
public ViolationEventService(
|
||||
ISnapshotStore snapshots,
|
||||
IOrchestratorJobStore jobs,
|
||||
IViolationEventStore store)
|
||||
{
|
||||
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||
_jobs = jobs ?? throw new ArgumentNullException(nameof(jobs));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ViolationEvent>> EmitAsync(
|
||||
ViolationEventRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = await _store.GetEventsAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false);
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var snapshot = await _snapshots.GetAsync(request.SnapshotId, cancellationToken).ConfigureAwait(false)
|
||||
?? throw new KeyNotFoundException($"Snapshot {request.SnapshotId} not found");
|
||||
|
||||
var events = new List<ViolationEvent>();
|
||||
foreach (var record in snapshot.Records)
|
||||
{
|
||||
if (string.Equals(record.Status, "ok", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var job = await _jobs.GetAsync(record.JobId, cancellationToken).ConfigureAwait(false);
|
||||
var policyProfileHash = job?.PolicyProfileHash ?? "unknown";
|
||||
|
||||
var severity = DeriveSeverity(record.Status);
|
||||
var eventId = StableIdGenerator.Sha256Hex($"{snapshot.SnapshotId}|{record.ComponentPurl}|{record.AdvisoryId}");
|
||||
|
||||
events.Add(new ViolationEvent(
|
||||
EventId: eventId,
|
||||
TenantId: record.TenantId,
|
||||
SnapshotId: snapshot.SnapshotId,
|
||||
PolicyProfileHash: policyProfileHash,
|
||||
ComponentPurl: record.ComponentPurl,
|
||||
AdvisoryId: record.AdvisoryId,
|
||||
ViolationCode: "policy.violation.detected",
|
||||
Severity: severity,
|
||||
Status: record.Status,
|
||||
TraceRef: record.TraceRef,
|
||||
OccurredAt: snapshot.GeneratedAt));
|
||||
}
|
||||
|
||||
await _store.SaveEventsAsync(snapshot.SnapshotId, events, cancellationToken).ConfigureAwait(false);
|
||||
return events;
|
||||
}
|
||||
|
||||
private static string DeriveSeverity(string status) => status switch
|
||||
{
|
||||
"violation" => "high",
|
||||
"warn" => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
internal interface IViolationEventStore
|
||||
{
|
||||
Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class InMemoryViolationEventStore : IViolationEventStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<ViolationEvent>> _events = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<SeverityFusionResult>> _fusion = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, IReadOnlyList<ConflictRecord>> _conflicts = new(StringComparer.Ordinal);
|
||||
|
||||
public Task SaveEventsAsync(string snapshotId, IReadOnlyList<ViolationEvent> events, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events[snapshotId] = events;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ViolationEvent>> GetEventsAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_events.TryGetValue(snapshotId, out var events);
|
||||
return Task.FromResult(events ?? (IReadOnlyList<ViolationEvent>)Array.Empty<ViolationEvent>());
|
||||
}
|
||||
|
||||
public Task SaveFusionAsync(string snapshotId, IReadOnlyList<SeverityFusionResult> results, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_fusion[snapshotId] = results;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SeverityFusionResult>> GetFusionAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_fusion.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value ?? (IReadOnlyList<SeverityFusionResult>)Array.Empty<SeverityFusionResult>());
|
||||
}
|
||||
|
||||
public Task SaveConflictsAsync(string snapshotId, IReadOnlyList<ConflictRecord> conflicts, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conflicts[snapshotId] = conflicts;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ConflictRecord>> GetConflictsAsync(string snapshotId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_conflicts.TryGetValue(snapshotId, out var value);
|
||||
return Task.FromResult(value ?? (IReadOnlyList<ConflictRecord>)Array.Empty<ConflictRecord>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Violations;
|
||||
|
||||
internal sealed record ViolationEventRequest(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId);
|
||||
|
||||
internal sealed record ViolationEvent(
|
||||
[property: JsonPropertyName("event_id")] string EventId,
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("policy_profile_hash")] string PolicyProfileHash,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("violation_code")] string ViolationCode,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("trace_ref")] string TraceRef,
|
||||
[property: JsonPropertyName("occurred_at")] string OccurredAt);
|
||||
|
||||
internal sealed record SeveritySource(
|
||||
[property: JsonPropertyName("source")] string Source,
|
||||
[property: JsonPropertyName("weight")] decimal Weight,
|
||||
[property: JsonPropertyName("severity")] string Severity,
|
||||
[property: JsonPropertyName("score")] decimal Score);
|
||||
|
||||
internal sealed record SeverityFusionResult(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("severity_fused")] string SeverityFused,
|
||||
[property: JsonPropertyName("score")] decimal Score,
|
||||
[property: JsonPropertyName("sources")] IReadOnlyList<SeveritySource> Sources,
|
||||
[property: JsonPropertyName("reason_codes")] IReadOnlyList<string> ReasonCodes);
|
||||
|
||||
internal sealed record ConflictRecord(
|
||||
[property: JsonPropertyName("tenant_id")] string TenantId,
|
||||
[property: JsonPropertyName("component_purl")] string ComponentPurl,
|
||||
[property: JsonPropertyName("advisory_id")] string AdvisoryId,
|
||||
[property: JsonPropertyName("conflicts")] IReadOnlyList<SeverityFusionResult> Conflicts,
|
||||
[property: JsonPropertyName("resolved_status")] string? ResolvedStatus);
|
||||
|
||||
internal sealed record ConflictRequest(
|
||||
[property: JsonPropertyName("snapshot_id")] string SnapshotId);
|
||||
@@ -0,0 +1,31 @@
|
||||
using StellaOps.Policy.Engine.AdvisoryAI;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class AdvisoryAiKnobsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
var profile = service.Get();
|
||||
|
||||
Assert.NotEmpty(profile.Knobs);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_NormalizesOrdering()
|
||||
{
|
||||
var service = new AdvisoryAiKnobsService(TimeProvider.System);
|
||||
|
||||
var profile = service.Set(new[]
|
||||
{
|
||||
new AdvisoryAiKnob("Time_Decay_Half_Life_Days", 20m, 1m, 365m, 1m, "decay"),
|
||||
new AdvisoryAiKnob("ai_signal_weight", 1.5m, 0m, 2m, 0.1m, "weight")
|
||||
});
|
||||
|
||||
Assert.Equal("ai_signal_weight", profile.Knobs[0].Name);
|
||||
Assert.Equal("time_decay_half_life_days", profile.Knobs[1].Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class LedgerExportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task BuildAsync_ProducesOrderedNdjson()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T15:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var service = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-1",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1")
|
||||
},
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var result = new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[]
|
||||
{
|
||||
new WorkerResultItem("pkg:b", "ADV-2", "ok", "trace-b"),
|
||||
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a")
|
||||
},
|
||||
"hash");
|
||||
|
||||
await resultStore.SaveAsync(result);
|
||||
|
||||
var export = await service.BuildAsync(new LedgerExportRequest("acme"));
|
||||
|
||||
Assert.Equal(2, export.Manifest.RecordCount);
|
||||
Assert.Equal("policy-ledger-export-v1", export.Manifest.SchemaVersion);
|
||||
Assert.Equal(3, export.Lines.Count); // manifest + 2 records
|
||||
Assert.Contains(export.Records, r => r.ComponentPurl == "pkg:a");
|
||||
Assert.Equal("pkg:a", export.Records[0].ComponentPurl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class OrchestratorJobServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SubmitAsync_NormalizesOrderingAndHashes()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T10:00:00Z"));
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var job = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx-123",
|
||||
PolicyProfileHash: "overlay-hash",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1")
|
||||
},
|
||||
Priority: "HIGH",
|
||||
TraceRef: null,
|
||||
Callbacks: new OrchestratorJobCallbacks("sse://events", "nats.subject"),
|
||||
RequestedAt: null));
|
||||
|
||||
Assert.Equal("acme", job.TenantId);
|
||||
Assert.Equal("ctx-123", job.ContextId);
|
||||
Assert.Equal("high", job.Priority);
|
||||
Assert.Equal(clock.GetUtcNow(), job.RequestedAt);
|
||||
Assert.Equal("queued", job.Status);
|
||||
Assert.Equal(2, job.BatchItems.Count);
|
||||
Assert.Equal("pkg:npm/alpha@1.0.0", job.BatchItems[0].ComponentPurl);
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.JobId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(job.DeterminismHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitAsync_IsDeterministicAcrossOrdering()
|
||||
{
|
||||
var requestedAt = DateTimeOffset.Parse("2025-11-24T11:00:00Z");
|
||||
var clock = new FakeTimeProvider(requestedAt);
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var first = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1"),
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2")
|
||||
},
|
||||
RequestedAt: requestedAt));
|
||||
|
||||
var second = await service.SubmitAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:b", "ADV-2"),
|
||||
new OrchestratorJobItem("pkg:a", "ADV-1")
|
||||
},
|
||||
RequestedAt: requestedAt));
|
||||
|
||||
Assert.Equal(first.JobId, second.JobId);
|
||||
Assert.Equal(first.DeterminismHash, second.DeterminismHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_DoesNotPersist()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T12:00:00Z"));
|
||||
var store = new InMemoryOrchestratorJobStore();
|
||||
var service = new OrchestratorJobService(clock, store);
|
||||
|
||||
var preview = await service.PreviewAsync(
|
||||
new OrchestratorJobRequest(
|
||||
"tenant",
|
||||
"ctx",
|
||||
"hash",
|
||||
new[] { new OrchestratorJobItem("pkg:a", "ADV-1") }));
|
||||
|
||||
Assert.Equal("preview", preview.Status);
|
||||
|
||||
var fetched = await store.GetAsync(preview.JobId);
|
||||
Assert.Null(fetched);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Policy.Engine.Overlay;
|
||||
using StellaOps.Policy.Engine.Tests.Fakes;
|
||||
@@ -8,6 +9,8 @@ namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PathScopeSimulationBridgeServiceTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task SimulateAsync_OrdersByInputAndProducesMetrics()
|
||||
{
|
||||
@@ -28,8 +31,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Equal(2, result.Decisions.Count);
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope));
|
||||
Assert.Contains("\"filePath\":\"b/file.js\"", JsonSerializer.Serialize(result.Decisions[0].PathScope, SerializerOptions));
|
||||
Assert.Contains("\"filePath\":\"a/file.js\"", JsonSerializer.Serialize(result.Decisions[1].PathScope, SerializerOptions));
|
||||
Assert.Equal(2, result.Metrics.Evaluated);
|
||||
}
|
||||
|
||||
@@ -49,7 +52,8 @@ public sealed class PathScopeSimulationBridgeServiceTests
|
||||
var result = await bridge.SimulateAsync(request);
|
||||
|
||||
Assert.Single(result.Decisions);
|
||||
Assert.Single(result.Deltas);
|
||||
Assert.NotNull(result.Deltas);
|
||||
Assert.Single(result.Deltas!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class PolicyWorkerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_ReturnsDeterministicResults()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T13:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var service = new PolicyWorkerService(clock, jobStore, resultStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "01HZX1QJP6Z3MNA0Q2T3VCPV5K",
|
||||
TenantId: "tenant",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[]
|
||||
{
|
||||
new OrchestratorJobItem("pkg:npm/alpha@1.0.0", "ADV-1"),
|
||||
new OrchestratorJobItem("pkg:npm/zeta@1.0.0", "ADV-2")
|
||||
},
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "queued",
|
||||
DeterminismHash: "hash-determinism");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var result = await service.ExecuteAsync(new WorkerRunRequest(job.JobId), CancellationToken.None);
|
||||
|
||||
Assert.Equal(job.JobId, result.JobId);
|
||||
Assert.Equal("worker-stub", result.WorkerId);
|
||||
Assert.Equal(2, result.Results.Count);
|
||||
Assert.True(result.Results.All(r => !string.IsNullOrWhiteSpace(r.Status)));
|
||||
|
||||
var fetched = await resultStore.GetByJobIdAsync(job.JobId);
|
||||
Assert.NotNull(fetched);
|
||||
Assert.Equal(result.ResultHash, fetched!.ResultHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_IsIdempotentOnRetry()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T14:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var service = new PolicyWorkerService(clock, jobStore, resultStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-id",
|
||||
TenantId: "tenant",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "queued",
|
||||
DeterminismHash: "hash");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
|
||||
var first = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
|
||||
var second = await service.ExecuteAsync(new WorkerRunRequest(job.JobId));
|
||||
|
||||
Assert.Equal(first.ResultHash, second.ResultHash);
|
||||
Assert.Equal(first.CompletedAt, second.CompletedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class SnapshotServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreateAsync_ProducesSnapshotFromLedger()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T16:00:00Z"));
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
var snapshotStore = new InMemorySnapshotStore();
|
||||
var service = new SnapshotService(clock, ledger, snapshotStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-xyz",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
await jobStore.SaveAsync(job);
|
||||
await resultStore.SaveAsync(new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[] { new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-ref") },
|
||||
"hash"));
|
||||
|
||||
await ledger.BuildAsync(new LedgerExportRequest("acme"));
|
||||
|
||||
var snapshot = await service.CreateAsync(new SnapshotRequest("acme", "overlay-1"));
|
||||
|
||||
Assert.Equal("acme", snapshot.TenantId);
|
||||
Assert.Equal("overlay-1", snapshot.OverlayHash);
|
||||
Assert.Single(snapshot.Records);
|
||||
Assert.Contains("violation", snapshot.StatusCounts.Keys);
|
||||
|
||||
var list = await service.ListAsync("acme");
|
||||
Assert.Single(list.Items);
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Policy.Engine/StellaOps.Policy.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class TrustWeightingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Get_ReturnsDefaultsWithHash()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
|
||||
var profile = service.Get();
|
||||
|
||||
Assert.NotEmpty(profile.Weights);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_NormalizesOrderingAndScale()
|
||||
{
|
||||
var service = new TrustWeightingService(TimeProvider.System);
|
||||
var now = TimeProvider.System.GetUtcNow().ToString("O");
|
||||
|
||||
var profile = service.Set(new[]
|
||||
{
|
||||
new TrustWeightingEntry("Scanner", 1.2345m, " hi ", now),
|
||||
new TrustWeightingEntry("cartographer", 0.9999m, null, now)
|
||||
});
|
||||
|
||||
Assert.Equal(2, profile.Weights.Count);
|
||||
Assert.Equal("cartographer", profile.Weights[0].Source);
|
||||
Assert.Equal(0.999m, profile.Weights[0].Weight);
|
||||
Assert.Equal(1.234m, profile.Weights[1].Weight);
|
||||
Assert.False(string.IsNullOrWhiteSpace(profile.ProfileHash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Policy.Engine.Ledger;
|
||||
using StellaOps.Policy.Engine.Orchestration;
|
||||
using StellaOps.Policy.Engine.Snapshots;
|
||||
using StellaOps.Policy.Engine.TrustWeighting;
|
||||
using StellaOps.Policy.Engine.Violations;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Tests;
|
||||
|
||||
public sealed class ViolationServicesTests
|
||||
{
|
||||
private static (ViolationEventService events, SeverityFusionService fusion, ConflictHandlingService conflicts, string snapshotId) BuildPipeline()
|
||||
{
|
||||
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-11-24T17:00:00Z"));
|
||||
|
||||
var jobStore = new InMemoryOrchestratorJobStore();
|
||||
var resultStore = new InMemoryWorkerResultStore(jobStore);
|
||||
var exportStore = new InMemoryLedgerExportStore();
|
||||
var ledger = new LedgerExportService(clock, jobStore, resultStore, exportStore);
|
||||
var snapshotStore = new InMemorySnapshotStore();
|
||||
var violationStore = new InMemoryViolationEventStore();
|
||||
var trust = new TrustWeightingService(clock);
|
||||
|
||||
var snapshotService = new SnapshotService(clock, ledger, snapshotStore);
|
||||
var eventService = new ViolationEventService(snapshotStore, jobStore, violationStore);
|
||||
var fusionService = new SeverityFusionService(violationStore, trust);
|
||||
var conflictService = new ConflictHandlingService(violationStore);
|
||||
|
||||
var job = new OrchestratorJob(
|
||||
JobId: "job-viol",
|
||||
TenantId: "acme",
|
||||
ContextId: "ctx",
|
||||
PolicyProfileHash: "hash",
|
||||
RequestedAt: clock.GetUtcNow(),
|
||||
Priority: "normal",
|
||||
BatchItems: new[] { new OrchestratorJobItem("pkg:a", "ADV-1"), new OrchestratorJobItem("pkg:b", "ADV-2") },
|
||||
Callbacks: null,
|
||||
TraceRef: "trace",
|
||||
Status: "completed",
|
||||
DeterminismHash: "hash",
|
||||
CompletedAt: clock.GetUtcNow(),
|
||||
ResultHash: "res");
|
||||
|
||||
jobStore.SaveAsync(job).GetAwaiter().GetResult();
|
||||
|
||||
resultStore.SaveAsync(new WorkerRunResult(
|
||||
job.JobId,
|
||||
"worker",
|
||||
clock.GetUtcNow(),
|
||||
clock.GetUtcNow(),
|
||||
new[]
|
||||
{
|
||||
new WorkerResultItem("pkg:a", "ADV-1", "violation", "trace-a"),
|
||||
new WorkerResultItem("pkg:b", "ADV-2", "warn", "trace-b")
|
||||
},
|
||||
"hash")).GetAwaiter().GetResult();
|
||||
|
||||
ledger.BuildAsync(new LedgerExportRequest("acme")).GetAwaiter().GetResult();
|
||||
var snapshot = snapshotService.CreateAsync(new SnapshotRequest("acme", "overlay-1")).GetAwaiter().GetResult();
|
||||
|
||||
return (eventService, fusionService, conflictService, snapshot.SnapshotId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitAsync_BuildsEvents()
|
||||
{
|
||||
var (eventService, _, _, snapshotId) = BuildPipeline();
|
||||
|
||||
var events = await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
|
||||
Assert.Equal(2, events.Count);
|
||||
Assert.All(events, e => Assert.Equal("policy.violation.detected", e.ViolationCode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FuseAsync_ProducesWeightedSeverity()
|
||||
{
|
||||
var (eventService, fusionService, _, snapshotId) = BuildPipeline();
|
||||
|
||||
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
var fused = await fusionService.FuseAsync(snapshotId);
|
||||
|
||||
Assert.Equal(2, fused.Count);
|
||||
Assert.All(fused, f => Assert.False(string.IsNullOrWhiteSpace(f.SeverityFused)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConflictsAsync_DetectsDivergentSeverities()
|
||||
{
|
||||
var (eventService, fusionService, conflictService, snapshotId) = BuildPipeline();
|
||||
await eventService.EmitAsync(new ViolationEventRequest(snapshotId));
|
||||
var fused = await fusionService.FuseAsync(snapshotId);
|
||||
|
||||
var conflicts = await conflictService.ComputeAsync(snapshotId, fused);
|
||||
|
||||
// Only triggers when severities differ; in this stub they do, so expect at least one.
|
||||
Assert.NotNull(conflicts);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class EntrypointEndpointsTests : IClassFixture<SbomServiceWebApplicationFactory>
|
||||
public class EntrypointEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly SbomServiceWebApplicationFactory _factory;
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public EntrypointEndpointsTests(SbomServiceWebApplicationFactory factory)
|
||||
public EntrypointEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.SbomService.Models;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class OrchestratorEndpointsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public OrchestratorEndpointsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_sources_requires_tenant()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/internal/orchestrator/sources");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_and_register_sources_are_deterministic()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var seeded = await client.GetFromJsonAsync<JsonElement>("/internal/orchestrator/sources?tenant=tenant-a");
|
||||
seeded.TryGetProperty("items", out var items).Should().BeTrue();
|
||||
items.GetArrayLength().Should().BeGreaterOrEqualTo(1);
|
||||
|
||||
var request = new RegisterOrchestratorSourceRequest(
|
||||
TenantId: "tenant-a",
|
||||
ArtifactDigest: "sha256:new123",
|
||||
SourceType: "scanner-index",
|
||||
Metadata: "seeded:test");
|
||||
|
||||
var post = await client.PostAsJsonAsync("/internal/orchestrator/sources", request);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var created = await post.Content.ReadFromJsonAsync<OrchestratorSource>();
|
||||
created.Should().NotBeNull();
|
||||
created!.ArtifactDigest.Should().Be("sha256:new123");
|
||||
|
||||
// Idempotent on digest+type
|
||||
var postAgain = await client.PostAsJsonAsync("/internal/orchestrator/sources", request);
|
||||
postAgain.EnsureSuccessStatusCode();
|
||||
var again = await postAgain.Content.ReadFromJsonAsync<OrchestratorSource>();
|
||||
again.Should().NotBeNull();
|
||||
again!.SourceId.Should().Be(created.SourceId);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ public class ProjectionEndpointTests : IClassFixture<WebApplicationFactory<Progr
|
||||
json.tenantId.Should().Be("tenant-a");
|
||||
json.hash.Should().NotBeNullOrEmpty();
|
||||
json.projection.GetProperty("purl").GetString().Should().Be("pkg:npm/lodash@4.17.21");
|
||||
var metadata = json.projection.GetProperty("metadata");
|
||||
metadata.GetProperty("asset").GetProperty("criticality").GetString().Should().Be("high");
|
||||
}
|
||||
|
||||
private sealed record ProjectionResponse(string snapshotId, string tenantId, string schemaVersion, string hash, System.Text.Json.JsonElement projection);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.SbomService.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class ResolverFeedExportTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public ResolverFeedExportTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Export_returns_ndjson_in_deterministic_order()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
// ensure feed populated
|
||||
await client.PostAsync("/internal/sbom/resolver-feed/backfill", null);
|
||||
|
||||
var response = await client.GetAsync("/internal/sbom/resolver-feed/export");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType!.MediaType.Should().Be("application/x-ndjson");
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
lines.Length.Should().BeGreaterThan(0);
|
||||
|
||||
// verify deterministic ordering by first and last line comparison
|
||||
var first = lines.First();
|
||||
var last = lines.Last();
|
||||
first.Should().BeLessOrEqualTo(last, Comparer<string>.Create(StringComparer.Ordinal.Compare));
|
||||
|
||||
// spot-check a known candidate
|
||||
var candidates = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
|
||||
candidates.Should().NotBeNull();
|
||||
candidates!.Any(c => c.Purl == "pkg:npm/lodash@4.17.21").Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.SbomService.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class SbomAssetEventsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public SbomAssetEventsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Projection_emits_asset_event_once()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var assetEvents = await client.GetFromJsonAsync<List<SbomAssetUpdatedEvent>>("/internal/sbom/asset-events");
|
||||
assetEvents.Should().NotBeNull();
|
||||
var events = assetEvents!;
|
||||
events.Should().HaveCount(1);
|
||||
|
||||
var evt = events[0];
|
||||
evt.SnapshotId.Should().Be("snap-001");
|
||||
evt.TenantId.Should().Be("tenant-a");
|
||||
evt.Asset.Criticality.Should().Be("high");
|
||||
evt.Asset.Exposure.Should().Contain("internet");
|
||||
evt.Asset.Tags.Should().ContainKey("service");
|
||||
|
||||
// Second call should be idempotent
|
||||
var again = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
|
||||
again.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var assetEventsAfter = await client.GetFromJsonAsync<List<SbomAssetUpdatedEvent>>("/internal/sbom/asset-events");
|
||||
assetEventsAfter.Should().NotBeNull();
|
||||
assetEventsAfter!.Should().HaveCount(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using StellaOps.SbomService.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.SbomService.Tests;
|
||||
|
||||
public class SbomInventoryEventsTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public SbomInventoryEventsTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inventory_events_emitted_on_projection()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var projection = await client.GetAsync("/sboms/snap-001/projection?tenant=tenant-a");
|
||||
projection.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var inventory = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
|
||||
inventory.Should().NotBeNull();
|
||||
var items = inventory!;
|
||||
items.Should().NotBeEmpty();
|
||||
items.Should().ContainSingle(i => i.Purl == "pkg:npm/lodash@4.17.21" && i.Scope == "runtime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Inventory_backfill_resets_and_replays()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var pre = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
|
||||
pre.Should().NotBeNull();
|
||||
|
||||
var backfill = await client.PostAsync("/internal/sbom/inventory/backfill", null);
|
||||
backfill.EnsureSuccessStatusCode();
|
||||
|
||||
var post = await client.GetFromJsonAsync<List<SbomInventoryEvidence>>("/internal/sbom/inventory");
|
||||
post.Should().NotBeNull();
|
||||
post!.Count.Should().BeGreaterOrEqualTo(pre!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolver_feed_backfill_populates_candidates()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
|
||||
var before = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
|
||||
before.Should().NotBeNull();
|
||||
|
||||
var resp = await client.PostAsync("/internal/sbom/resolver-feed/backfill", null);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var feed = await client.GetFromJsonAsync<List<ResolverCandidate>>("/internal/sbom/resolver-feed");
|
||||
feed.Should().NotBeNull();
|
||||
feed!.Should().NotBeEmpty();
|
||||
feed.Should().Contain(c => c.Purl == "pkg:npm/lodash@4.17.21");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record OrchestratorSource(
|
||||
string TenantId,
|
||||
string SourceId,
|
||||
string ArtifactDigest,
|
||||
string SourceType,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
string Metadata);
|
||||
|
||||
public sealed record RegisterOrchestratorSourceRequest(
|
||||
string TenantId,
|
||||
string ArtifactDigest,
|
||||
string SourceType,
|
||||
string Metadata);
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record ResolverCandidate(
|
||||
string TenantId,
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string Version,
|
||||
IReadOnlyList<string> Paths,
|
||||
string Scope,
|
||||
bool RuntimeFlag,
|
||||
string NearestSafeVersion);
|
||||
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record AssetMetadata(
|
||||
string Criticality,
|
||||
string Owner,
|
||||
string Environment,
|
||||
IReadOnlyList<string> Exposure,
|
||||
IReadOnlyDictionary<string, string> Tags);
|
||||
|
||||
public sealed record SbomAssetUpdatedEvent(
|
||||
string SnapshotId,
|
||||
string TenantId,
|
||||
AssetMetadata Asset,
|
||||
string ProjectionHash,
|
||||
string SchemaVersion,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.SbomService.Models;
|
||||
|
||||
public sealed record SbomInventoryEvidence(
|
||||
string SnapshotId,
|
||||
string TenantId,
|
||||
string Artifact,
|
||||
string Purl,
|
||||
string Scope,
|
||||
bool RuntimeFlag,
|
||||
string NearestSafeVersion,
|
||||
IReadOnlyList<string> Path);
|
||||
@@ -4,8 +4,9 @@ Artifacts added for SBOM-AIAI-31-002 (Advisory AI endpoints):
|
||||
|
||||
- `sbomservice-grafana-dashboard.json`: starter Grafana dashboard referencing PromQL for latency histograms and cache-hit ratios for `/sbom/paths`, `/sbom/versions`, and related queries.
|
||||
|
||||
Notes:
|
||||
- Metrics names match Program.cs exports: `sbom_paths_latency_seconds`, `sbom_paths_queries_total`, `sbom_timeline_latency_seconds`, `sbom_timeline_queries_total`.
|
||||
- Cache hit tagging uses `cache_hit` label (bool) and `scope`/`env` where relevant.
|
||||
Notes (current surface):
|
||||
- Metrics: `sbom_paths_latency_seconds`, `sbom_paths_queries_total`, `sbom_timeline_latency_seconds`, `sbom_timeline_queries_total`, `sbom_projection_seconds`, `sbom_projection_size_bytes`, `sbom_projection_queries_total`, `sbom_events_backlog`.
|
||||
- Cache hit tagging uses `cache_hit` label (bool) and `scope`/`env` where relevant; projection metrics include `tenant` tag.
|
||||
- Tracing: ActivitySource `StellaOps.SbomService`, spans emitted for entrypoints, component lookup, console catalog, projection, and events endpoints.
|
||||
- Dashboard is schemaVersion 39; adjust datasource UID at import.
|
||||
- Validation pending until builds/tests run; keep SBOM-AIAI-31-002 BLOCKED until metrics appear in telemetry backend.
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace StellaOps.SbomService.Observability;
|
||||
|
||||
internal static class SbomMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("StellaOps.SbomService");
|
||||
internal static readonly Meter Meter = new("StellaOps.SbomService");
|
||||
|
||||
public static readonly Histogram<double> PathsLatencySeconds =
|
||||
Meter.CreateHistogram<double>("sbom_paths_latency_seconds", unit: "s",
|
||||
@@ -36,4 +36,13 @@ internal static class SbomMetrics
|
||||
|
||||
public static readonly Histogram<long> EventBacklogSize =
|
||||
Meter.CreateHistogram<long>("sbom_events_backlog", unit: "events",
|
||||
description: "Observed size of the SBOM event outbox (in-memory)
|
||||
description: "Observed size of the SBOM event outbox (in-memory)");
|
||||
|
||||
public static readonly Counter<long> OrchestratorControlUpdates =
|
||||
Meter.CreateCounter<long>("sbom_orchestrator_control_updates",
|
||||
description: "Total orchestrator control updates (pause/throttle/backpressure) by tenant");
|
||||
|
||||
public static readonly Counter<long> ResolverFeedPublished =
|
||||
Meter.CreateCounter<long>("sbom_resolver_feed_published",
|
||||
description: "Resolver feed candidates published");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Globalization;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Services;
|
||||
@@ -25,6 +24,13 @@ builder.Services.AddSingleton<ISbomEventStore, InMemorySbomEventStore>();
|
||||
builder.Services.AddSingleton<ISbomEventPublisher>(sp => sp.GetRequiredService<ISbomEventStore>());
|
||||
builder.Services.AddSingleton<ISbomQueryService, InMemorySbomQueryService>();
|
||||
builder.Services.AddSingleton<IEntrypointRepository, InMemoryEntrypointRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorRepository, InMemoryOrchestratorRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorControlRepository, InMemoryOrchestratorControlRepository>();
|
||||
builder.Services.AddSingleton<IOrchestratorControlService>(sp =>
|
||||
new OrchestratorControlService(
|
||||
sp.GetRequiredService<IOrchestratorControlRepository>(),
|
||||
SbomMetrics.Meter));
|
||||
builder.Services.AddSingleton<IWatermarkService, InMemoryWatermarkService>();
|
||||
|
||||
builder.Services.AddSingleton<IProjectionRepository>(sp =>
|
||||
{
|
||||
@@ -364,7 +370,30 @@ app.MapGet("/internal/sbom/events", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
using var activity = SbomTracing.Source.StartActivity("events.list", ActivityKind.Server);
|
||||
var events = await store.ListAsync(cancellationToken);
|
||||
SbomMetrics.EventBacklogSize.Record(events.Count);
|
||||
|
||||
if (events.Count > 100)
|
||||
{
|
||||
app.Logger.LogWarning("sbom event backlog high: {Count}", events.Count);
|
||||
}
|
||||
return Results.Ok(events);
|
||||
});
|
||||
|
||||
app.MapGet("/internal/sbom/asset-events", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
using var activity = SbomTracing.Source.StartActivity("asset-events.list", ActivityKind.Server);
|
||||
var events = await store.ListAssetsAsync(cancellationToken);
|
||||
SbomMetrics.EventBacklogSize.Record(events.Count);
|
||||
|
||||
if (events.Count > 100)
|
||||
{
|
||||
app.Logger.LogWarning("sbom asset event backlog high: {Count}", events.Count);
|
||||
}
|
||||
|
||||
return Results.Ok(events);
|
||||
});
|
||||
|
||||
@@ -390,9 +419,166 @@ app.MapPost("/internal/sbom/events/backfill", async Task<IResult> (
|
||||
}
|
||||
}
|
||||
|
||||
SbomMetrics.EventBacklogSize.Record(published);
|
||||
if (published > 0)
|
||||
{
|
||||
app.Logger.LogInformation("sbom events backfilled={Count}", published);
|
||||
}
|
||||
return Results.Ok(new { published });
|
||||
});
|
||||
|
||||
app.MapGet("/internal/sbom/inventory", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
using var activity = SbomTracing.Source.StartActivity("inventory.list", ActivityKind.Server);
|
||||
var items = await store.ListInventoryAsync(cancellationToken);
|
||||
return Results.Ok(items);
|
||||
});
|
||||
|
||||
app.MapPost("/internal/sbom/inventory/backfill", async Task<IResult> (
|
||||
[FromServices] ISbomQueryService service,
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
// clear existing inventory and replay by listing projections
|
||||
await store.ClearInventoryAsync(cancellationToken);
|
||||
var projections = new[] { ("snap-001", "tenant-a") };
|
||||
var published = 0;
|
||||
foreach (var (snapshot, tenant) in projections)
|
||||
{
|
||||
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
|
||||
published++;
|
||||
}
|
||||
return Results.Ok(new { published });
|
||||
});
|
||||
|
||||
app.MapGet("/internal/sbom/resolver-feed", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var feed = await store.ListResolverAsync(cancellationToken);
|
||||
return Results.Ok(feed);
|
||||
});
|
||||
|
||||
app.MapPost("/internal/sbom/resolver-feed/backfill", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
[FromServices] ISbomQueryService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
await store.ClearResolverAsync(cancellationToken);
|
||||
var projections = new[] { ("snap-001", "tenant-a") };
|
||||
foreach (var (snapshot, tenant) in projections)
|
||||
{
|
||||
await service.GetProjectionAsync(snapshot, tenant, cancellationToken);
|
||||
}
|
||||
var feed = await store.ListResolverAsync(cancellationToken);
|
||||
return Results.Ok(new { published = feed.Count });
|
||||
});
|
||||
|
||||
app.MapGet("/internal/sbom/resolver-feed/export", async Task<IResult> (
|
||||
[FromServices] ISbomEventStore store,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var feed = await store.ListResolverAsync(cancellationToken);
|
||||
var lines = feed.Select(candidate => JsonSerializer.Serialize(candidate));
|
||||
var ndjson = string.Join('\n', lines);
|
||||
return Results.Text(ndjson, "application/x-ndjson");
|
||||
});
|
||||
|
||||
app.MapGet("/internal/orchestrator/sources", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IOrchestratorRepository repository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
|
||||
var sources = await repository.ListAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(new { tenant = tenant.Trim(), items = sources });
|
||||
});
|
||||
|
||||
app.MapPost("/internal/orchestrator/sources", async Task<IResult> (
|
||||
RegisterOrchestratorSourceRequest request,
|
||||
[FromServices] IOrchestratorRepository repository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return Results.BadRequest(new { error = "artifactDigest required" });
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.SourceType))
|
||||
{
|
||||
return Results.BadRequest(new { error = "sourceType required" });
|
||||
}
|
||||
|
||||
var source = await repository.RegisterAsync(request, cancellationToken);
|
||||
return Results.Ok(source);
|
||||
});
|
||||
|
||||
app.MapGet("/internal/orchestrator/control", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IOrchestratorControlService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
|
||||
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(state);
|
||||
});
|
||||
|
||||
app.MapPost("/internal/orchestrator/control", async Task<IResult> (
|
||||
OrchestratorControlRequest request,
|
||||
[FromServices] IOrchestratorControlService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
|
||||
var updated = await service.UpdateAsync(request, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
});
|
||||
|
||||
app.MapGet("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
[FromServices] IWatermarkService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
|
||||
var state = await service.GetAsync(tenant.Trim(), cancellationToken);
|
||||
return Results.Ok(state);
|
||||
});
|
||||
|
||||
app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
|
||||
[FromQuery] string? tenant,
|
||||
[FromQuery] string? watermark,
|
||||
[FromServices] IWatermarkService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant required" });
|
||||
}
|
||||
|
||||
var updated = await service.SetAsync(tenant.Trim(), watermark ?? string.Empty, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public interface IOrchestratorControlRepository
|
||||
{
|
||||
Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken);
|
||||
Task<OrchestratorControlState> SetAsync(OrchestratorControlState state, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<OrchestratorControlState>> ListAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
public interface IOrchestratorRepository
|
||||
{
|
||||
Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken);
|
||||
Task<OrchestratorSource> RegisterAsync(RegisterOrchestratorSourceRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.SbomService.Services;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
internal sealed class InMemoryOrchestratorControlRepository : IOrchestratorControlRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, OrchestratorControlState> _states = new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryOrchestratorControlRepository()
|
||||
{
|
||||
_states["tenant-a"] = OrchestratorControlState.Default("tenant-a");
|
||||
}
|
||||
|
||||
public Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_states.TryGetValue(tenantId, out var state))
|
||||
{
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
var created = OrchestratorControlState.Default(tenantId);
|
||||
_states[tenantId] = created;
|
||||
return Task.FromResult(created);
|
||||
}
|
||||
|
||||
public Task<OrchestratorControlState> SetAsync(OrchestratorControlState state, CancellationToken cancellationToken)
|
||||
{
|
||||
_states[state.TenantId] = state;
|
||||
return Task.FromResult(state);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OrchestratorControlState>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _states.Values
|
||||
.OrderBy(s => s.TenantId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<OrchestratorControlState>>(list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Concurrent;
|
||||
using StellaOps.SbomService.Models;
|
||||
|
||||
namespace StellaOps.SbomService.Repositories;
|
||||
|
||||
internal sealed class InMemoryOrchestratorRepository : IOrchestratorRepository
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<OrchestratorSource>> _sources = new(StringComparer.Ordinal);
|
||||
|
||||
public InMemoryOrchestratorRepository()
|
||||
{
|
||||
Seed();
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<OrchestratorSource>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_sources.TryGetValue(tenantId, out var list))
|
||||
{
|
||||
var ordered = list
|
||||
.OrderBy(s => s.ArtifactDigest, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SourceType, StringComparer.Ordinal)
|
||||
.ThenBy(s => s.SourceId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<OrchestratorSource>>(ordered);
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<OrchestratorSource>>(Array.Empty<OrchestratorSource>());
|
||||
}
|
||||
|
||||
public Task<OrchestratorSource> RegisterAsync(RegisterOrchestratorSourceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _sources.GetOrAdd(request.TenantId, _ => new List<OrchestratorSource>());
|
||||
var sourceId = $"src-{list.Count + 1:D3}";
|
||||
|
||||
var source = new OrchestratorSource(
|
||||
request.TenantId,
|
||||
sourceId,
|
||||
request.ArtifactDigest.Trim(),
|
||||
request.SourceType.Trim(),
|
||||
DateTimeOffset.UtcNow,
|
||||
request.Metadata.Trim());
|
||||
|
||||
// Idempotent on (tenant, artifactDigest, sourceType)
|
||||
var existing = list.FirstOrDefault(s =>
|
||||
s.ArtifactDigest.Equals(source.ArtifactDigest, StringComparison.Ordinal) &&
|
||||
s.SourceType.Equals(source.SourceType, StringComparison.Ordinal));
|
||||
if (existing is not null)
|
||||
{
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
|
||||
list.Add(source);
|
||||
return Task.FromResult(source);
|
||||
}
|
||||
|
||||
private void Seed()
|
||||
{
|
||||
_sources["tenant-a"] = new List<OrchestratorSource>
|
||||
{
|
||||
new(
|
||||
TenantId: "tenant-a",
|
||||
SourceId: "src-001",
|
||||
ArtifactDigest: "sha256:mock111",
|
||||
SourceType: "scanner-index",
|
||||
CreatedAtUtc: new DateTimeOffset(2025, 11, 20, 12, 0, 0, TimeSpan.Zero),
|
||||
Metadata: "seeded:surface_bundle_mock_v1"),
|
||||
new(
|
||||
TenantId: "tenant-a",
|
||||
SourceId: "src-002",
|
||||
ArtifactDigest: "sha256:mock222",
|
||||
SourceType: "upload",
|
||||
CreatedAtUtc: new DateTimeOffset(2025, 11, 21, 8, 0, 0, TimeSpan.Zero),
|
||||
Metadata: "seeded:spdx_upload")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using StellaOps.SbomService.Models;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
using StellaOps.SbomService.Services;
|
||||
@@ -186,15 +187,122 @@ internal sealed class InMemorySbomQueryService : ISbomQueryService
|
||||
projection.SchemaVersion,
|
||||
_clock.UtcNow);
|
||||
await _eventPublisher.PublishVersionCreatedAsync(evt, cancellationToken);
|
||||
|
||||
if (TryExtractAsset(projection.Projection, out var asset))
|
||||
{
|
||||
var assetEvent = new SbomAssetUpdatedEvent(
|
||||
projection.SnapshotId,
|
||||
projection.TenantId,
|
||||
asset,
|
||||
projection.ProjectionHash,
|
||||
projection.SchemaVersion,
|
||||
_clock.UtcNow);
|
||||
await _eventPublisher.PublishAssetUpdatedAsync(assetEvent, cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var inv in BuildInventoryEvents(projection.SnapshotId, projection.TenantId))
|
||||
{
|
||||
await _eventPublisher.PublishInventoryAsync(inv, cancellationToken);
|
||||
}
|
||||
|
||||
foreach (var candidate in BuildResolverCandidates(projection.SnapshotId, projection.TenantId))
|
||||
{
|
||||
await _eventPublisher.PublishResolverAsync(candidate, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return projection;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PathRecord> SeedPaths()
|
||||
{
|
||||
return new List<PathRecord>
|
||||
{
|
||||
private static bool TryExtractAsset(JsonElement projection, out AssetMetadata asset)
|
||||
{
|
||||
asset = default!;
|
||||
|
||||
if (!projection.TryGetProperty("metadata", out var metadata) ||
|
||||
!metadata.TryGetProperty("asset", out var assetElem))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string GetString(JsonElement element, string property) =>
|
||||
element.TryGetProperty(property, out var prop) && prop.ValueKind == JsonValueKind.String
|
||||
? prop.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
var criticality = GetString(assetElem, "criticality");
|
||||
var owner = GetString(assetElem, "owner");
|
||||
var environment = GetString(assetElem, "environment");
|
||||
|
||||
var exposure = new List<string>();
|
||||
if (assetElem.TryGetProperty("exposure", out var exposureElem) && exposureElem.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in exposureElem.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
|
||||
{
|
||||
exposure.Add(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tags = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (assetElem.TryGetProperty("tags", out var tagsElem) && tagsElem.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in tagsElem.EnumerateObject())
|
||||
{
|
||||
tags[prop.Name] = prop.Value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(criticality) && string.IsNullOrEmpty(owner) && string.IsNullOrEmpty(environment) && exposure.Count == 0 && tags.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
asset = new AssetMetadata(criticality, owner, environment, exposure, tags);
|
||||
return true;
|
||||
}
|
||||
|
||||
private IEnumerable<ResolverCandidate> BuildResolverCandidates(string snapshotId, string tenantId)
|
||||
{
|
||||
foreach (var path in _paths)
|
||||
{
|
||||
var pathNodes = path.Nodes.Select(n => n.Name).ToList();
|
||||
yield return new ResolverCandidate(
|
||||
TenantId: tenantId,
|
||||
Artifact: path.Artifact,
|
||||
Purl: path.Purl,
|
||||
Version: path.NearestSafeVersion ?? string.Empty,
|
||||
Paths: pathNodes,
|
||||
Scope: path.Scope ?? string.Empty,
|
||||
RuntimeFlag: path.RuntimeFlag,
|
||||
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty);
|
||||
|
||||
SbomMetrics.ResolverFeedPublished.Add(1, new TagList { { "tenant", tenantId } });
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<SbomInventoryEvidence> BuildInventoryEvents(string snapshotId, string tenantId)
|
||||
{
|
||||
foreach (var path in _paths)
|
||||
{
|
||||
var pathNodes = path.Nodes.Select(n => $"{n.Name}:{n.Type}").ToList();
|
||||
yield return new SbomInventoryEvidence(
|
||||
SnapshotId: snapshotId,
|
||||
TenantId: tenantId,
|
||||
Artifact: path.Artifact,
|
||||
Purl: path.Purl,
|
||||
Scope: path.Scope ?? string.Empty,
|
||||
RuntimeFlag: path.RuntimeFlag,
|
||||
NearestSafeVersion: path.NearestSafeVersion ?? string.Empty,
|
||||
Path: pathNodes);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PathRecord> SeedPaths()
|
||||
{
|
||||
return new List<PathRecord>
|
||||
{
|
||||
new(
|
||||
Artifact: "ghcr.io/stellaops/sample-api@sha256:111",
|
||||
Purl: "pkg:npm/lodash@4.17.21",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using StellaOps.SbomService.Repositories;
|
||||
|
||||
namespace StellaOps.SbomService.Services;
|
||||
|
||||
public sealed record OrchestratorControlState(
|
||||
string TenantId,
|
||||
bool Paused,
|
||||
int ThrottlePercent,
|
||||
string Backpressure,
|
||||
DateTimeOffset UpdatedAtUtc)
|
||||
{
|
||||
public static OrchestratorControlState Default(string tenantId) =>
|
||||
new(tenantId, false, 0, "normal", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
public sealed record OrchestratorControlRequest(
|
||||
string TenantId,
|
||||
bool? Paused,
|
||||
int? ThrottlePercent,
|
||||
string? Backpressure);
|
||||
|
||||
public interface IOrchestratorControlService
|
||||
{
|
||||
Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken);
|
||||
Task<OrchestratorControlState> UpdateAsync(OrchestratorControlRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class OrchestratorControlService : IOrchestratorControlService
|
||||
{
|
||||
private readonly IOrchestratorControlRepository _repository;
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _controlUpdates;
|
||||
private readonly ObservableGauge<int> _throttleGauge;
|
||||
private readonly ObservableGauge<int> _pausedGauge;
|
||||
|
||||
private readonly ConcurrentDictionary<string, OrchestratorControlState> _cache = new(StringComparer.Ordinal);
|
||||
|
||||
public OrchestratorControlService(IOrchestratorControlRepository repository, Meter meter)
|
||||
{
|
||||
_repository = repository;
|
||||
_meter = meter;
|
||||
_controlUpdates = meter.CreateCounter<long>("sbom_orchestrator_control_updates");
|
||||
_throttleGauge = meter.CreateObservableGauge("sbom_orchestrator_throttle_percent", ObserveThrottle);
|
||||
_pausedGauge = meter.CreateObservableGauge("sbom_orchestrator_paused", ObservePaused);
|
||||
}
|
||||
|
||||
public async Task<OrchestratorControlState> GetAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var state = await _repository.GetAsync(tenantId, cancellationToken);
|
||||
_cache[tenantId] = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
public async Task<OrchestratorControlState> UpdateAsync(OrchestratorControlRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var current = await _repository.GetAsync(request.TenantId, cancellationToken);
|
||||
|
||||
var throttle = request.ThrottlePercent.HasValue
|
||||
? Math.Clamp(request.ThrottlePercent.Value, 0, 100)
|
||||
: current.ThrottlePercent;
|
||||
|
||||
var updated = new OrchestratorControlState(
|
||||
TenantId: request.TenantId,
|
||||
Paused: request.Paused ?? current.Paused,
|
||||
ThrottlePercent: throttle,
|
||||
Backpressure: string.IsNullOrWhiteSpace(request.Backpressure) ? current.Backpressure : request.Backpressure!.Trim().ToLowerInvariant(),
|
||||
UpdatedAtUtc: DateTimeOffset.UtcNow);
|
||||
|
||||
await _repository.SetAsync(updated, cancellationToken);
|
||||
_cache[updated.TenantId] = updated;
|
||||
_controlUpdates.Add(1, new TagList { { "tenant", updated.TenantId } });
|
||||
return updated;
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<int>> ObserveThrottle()
|
||||
{
|
||||
foreach (var kvp in _cache)
|
||||
{
|
||||
yield return new Measurement<int>(kvp.Value.ThrottlePercent, new TagList { { "tenant", kvp.Key } });
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<int>> ObservePaused()
|
||||
{
|
||||
foreach (var kvp in _cache)
|
||||
{
|
||||
yield return new Measurement<int>(kvp.Value.Paused ? 1 : 0, new TagList { { "tenant", kvp.Key } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,16 +9,35 @@ public interface ISbomEventPublisher
|
||||
/// Publishes a version-created event. Returns true when the event was newly recorded; false when it was already present.
|
||||
/// </summary>
|
||||
Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes an asset-updated event (idempotent on snapshot+tenant+projection hash).
|
||||
/// </summary>
|
||||
Task<bool> PublishAssetUpdatedAsync(SbomAssetUpdatedEvent evt, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes inventory evidence for resolver jobs (idempotent on snapshot+tenant+purl+scope+runtimeFlag).
|
||||
/// </summary>
|
||||
Task<bool> PublishInventoryAsync(SbomInventoryEvidence evt, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface ISbomEventStore : ISbomEventPublisher
|
||||
{
|
||||
Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<SbomAssetUpdatedEvent>> ListAssetsAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<SbomInventoryEvidence>> ListInventoryAsync(CancellationToken cancellationToken);
|
||||
Task<bool> ClearInventoryAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<ResolverCandidate>> ListResolverAsync(CancellationToken cancellationToken);
|
||||
Task<bool> ClearResolverAsync(CancellationToken cancellationToken);
|
||||
Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class InMemorySbomEventStore : ISbomEventStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, SbomVersionCreatedEvent> _events = new();
|
||||
private readonly ConcurrentDictionary<string, SbomAssetUpdatedEvent> _assetEvents = new();
|
||||
private readonly ConcurrentDictionary<string, SbomInventoryEvidence> _inventoryEvents = new();
|
||||
private readonly ConcurrentDictionary<string, ResolverCandidate> _resolverEvents = new();
|
||||
|
||||
public Task<IReadOnlyList<SbomVersionCreatedEvent>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -28,10 +47,74 @@ public sealed class InMemorySbomEventStore : ISbomEventStore
|
||||
return Task.FromResult<IReadOnlyList<SbomVersionCreatedEvent>>(list);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomAssetUpdatedEvent>> ListAssetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _assetEvents.Values
|
||||
.OrderBy(e => e.SnapshotId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.TenantId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<SbomAssetUpdatedEvent>>(list);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SbomInventoryEvidence>> ListInventoryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _inventoryEvents.Values
|
||||
.OrderBy(e => e.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.SnapshotId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Artifact, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Purl, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<SbomInventoryEvidence>>(list);
|
||||
}
|
||||
|
||||
public Task<bool> ClearInventoryAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_inventoryEvents.Clear();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> PublishVersionCreatedAsync(SbomVersionCreatedEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.ProjectionHash}";
|
||||
var added = _events.TryAdd(key, evt);
|
||||
return Task.FromResult(added);
|
||||
}
|
||||
|
||||
public Task<bool> PublishAssetUpdatedAsync(SbomAssetUpdatedEvent evt, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.ProjectionHash}";
|
||||
var added = _assetEvents.TryAdd(key, evt);
|
||||
return Task.FromResult(added);
|
||||
}
|
||||
|
||||
public Task<bool> PublishInventoryAsync(SbomInventoryEvidence evt, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{evt.SnapshotId}|{evt.TenantId}|{evt.Artifact}|{evt.Purl}|{evt.Scope}|{evt.RuntimeFlag}";
|
||||
var added = _inventoryEvents.TryAdd(key, evt);
|
||||
return Task.FromResult(added);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ResolverCandidate>> ListResolverAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var list = _resolverEvents.Values
|
||||
.OrderBy(e => e.TenantId, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Artifact, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Purl, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Version, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<ResolverCandidate>>(list);
|
||||
}
|
||||
|
||||
public Task<bool> ClearResolverAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_resolverEvents.Clear();
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> PublishResolverAsync(ResolverCandidate candidate, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{candidate.TenantId}|{candidate.Artifact}|{candidate.Purl}|{candidate.Version}|{candidate.Scope}|{candidate.RuntimeFlag}";
|
||||
var added = _resolverEvents.TryAdd(key, candidate);
|
||||
return Task.FromResult(added);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user