Restructure solution layout by module
This commit is contained in:
118
src/Signals/StellaOps.Signals.sln
Normal file
118
src/Signals/StellaOps.Signals.sln
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Signals", "StellaOps.Signals\StellaOps.Signals.csproj", "{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Configuration", "..\__Libraries\StellaOps.Configuration\StellaOps.Configuration.csproj", "{4933EA43-D891-4080-A644-5D14F680F6F1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Authority.Plugins.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj", "{4B663300-18DB-44DA-95FB-7C2B02D7BF69}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Cryptography", "..\__Libraries\StellaOps.Cryptography\StellaOps.Cryptography.csproj", "{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.Abstractions", "..\Authority\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj", "{60D01EF6-9E65-447D-86DC-B140731B5513}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Auth.ServerIntegration", "..\Authority\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj", "{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.DependencyInjection", "..\__Libraries\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj", "{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DF8EEADB-1C60-46D6-B271-5742FE8F33EC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4933EA43-D891-4080-A644-5D14F680F6F1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4B663300-18DB-44DA-95FB-7C2B02D7BF69}.Release|x86.Build.0 = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x64.Build.0 = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{16E1FBA9-18D0-4912-A36E-691C7BAE0CF7}.Release|x86.Build.0 = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x64.Build.0 = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{60D01EF6-9E65-447D-86DC-B140731B5513}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2283F9AD-83C5-473E-BE71-FAD3A98FB0FE}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F7541F2C-CA8E-4D8E-A5DF-06E2E8F87F42}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
11
src/Signals/StellaOps.Signals/AGENTS.md
Normal file
11
src/Signals/StellaOps.Signals/AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# StellaOps.Signals — Agent Charter
|
||||
|
||||
## Mission
|
||||
Provide language-agnostic collection, normalization, and scoring of reachability and exploitability signals for Stella Ops. Accept static artifacts (call graphs, symbol references) and runtime context facts, derive normalized reachability states/scores, and expose them to Policy Engine, Web API, and Console without mutating advisory evidence.
|
||||
|
||||
## Expectations
|
||||
- Maintain deterministic scoring with full provenance (AOC chains).
|
||||
- Support incremental ingestion (per asset + snapshot) and expose caches for fast policy evaluation.
|
||||
- Coordinate with SBOM/Policy/Console guilds on schema changes and UI expectations.
|
||||
- Implement guardrails for large artifacts, authentication, and privacy (no PII).
|
||||
- Update `TASKS.md`, `../../docs/implplan/SPRINTS.md` as work progresses.
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Signals.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication handler used during development fallback.
|
||||
/// </summary>
|
||||
internal sealed class AnonymousAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
public AnonymousAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: base(options, logger, encoder)
|
||||
{
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
var identity = new ClaimsIdentity();
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var ticket = new AuthenticationTicket(principal, Scheme.Name);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Signals.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Header-based scope authorizer for development environments.
|
||||
/// </summary>
|
||||
internal static class HeaderScopeAuthorizer
|
||||
{
|
||||
internal static bool HasScope(ClaimsPrincipal principal, string requiredScope)
|
||||
{
|
||||
if (principal is null || string.IsNullOrWhiteSpace(requiredScope))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scopes = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
if (string.Equals(scope, requiredScope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static ClaimsPrincipal CreatePrincipal(string scopeBuffer)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(StellaOpsClaimTypes.Scope, scopeBuffer)
|
||||
};
|
||||
|
||||
foreach (var value in scopeBuffer.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, value));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, authenticationType: "Header");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Claims;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
|
||||
namespace StellaOps.Signals.Authentication;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for evaluating token scopes.
|
||||
/// </summary>
|
||||
internal static class TokenScopeAuthorizer
|
||||
{
|
||||
internal static bool HasScope(ClaimsPrincipal principal, string requiredScope)
|
||||
{
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.ScopeItem))
|
||||
{
|
||||
if (string.Equals(claim.Value, requiredScope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll(StellaOpsClaimTypes.Scope))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(claim.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var parts = claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
var normalized = StellaOpsScopes.Normalize(part);
|
||||
if (normalized is not null && string.Equals(normalized, requiredScope, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
12
src/Signals/StellaOps.Signals/Hosting/SignalsStartupState.cs
Normal file
12
src/Signals/StellaOps.Signals/Hosting/SignalsStartupState.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Signals.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks Signals service readiness state.
|
||||
/// </summary>
|
||||
public sealed class SignalsStartupState
|
||||
{
|
||||
/// <summary>
|
||||
/// Indicates whether the service is ready to accept requests.
|
||||
/// </summary>
|
||||
public bool IsReady { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing the stored raw callgraph artifact.
|
||||
/// </summary>
|
||||
public sealed class CallgraphArtifactMetadata
|
||||
{
|
||||
[BsonElement("path")]
|
||||
public string Path { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("hash")]
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("contentType")]
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("length")]
|
||||
public long Length { get; set; }
|
||||
}
|
||||
41
src/Signals/StellaOps.Signals/Models/CallgraphDocument.cs
Normal file
41
src/Signals/StellaOps.Signals/Models/CallgraphDocument.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB document representing an ingested callgraph.
|
||||
/// </summary>
|
||||
public sealed class CallgraphDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string Id { get; set; } = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
[BsonElement("language")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("component")]
|
||||
public string Component { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("version")]
|
||||
public string Version { get; set; } = string.Empty;
|
||||
|
||||
[BsonElement("ingestedAt")]
|
||||
public DateTimeOffset IngestedAt { get; set; }
|
||||
|
||||
[BsonElement("artifact")]
|
||||
public CallgraphArtifactMetadata Artifact { get; set; } = new();
|
||||
|
||||
[BsonElement("nodes")]
|
||||
public List<CallgraphNode> Nodes { get; set; } = new();
|
||||
|
||||
[BsonElement("edges")]
|
||||
public List<CallgraphEdge> Edges { get; set; } = new();
|
||||
|
||||
[BsonElement("metadata")]
|
||||
[BsonIgnoreIfNull]
|
||||
public Dictionary<string, string?>? Metadata { get; set; }
|
||||
}
|
||||
9
src/Signals/StellaOps.Signals/Models/CallgraphEdge.cs
Normal file
9
src/Signals/StellaOps.Signals/Models/CallgraphEdge.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph edge.
|
||||
/// </summary>
|
||||
public sealed record CallgraphEdge(
|
||||
string SourceId,
|
||||
string TargetId,
|
||||
string Type);
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// API request payload for callgraph ingestion.
|
||||
/// </summary>
|
||||
public sealed record CallgraphIngestRequest(
|
||||
[property: Required] string Language,
|
||||
[property: Required] string Component,
|
||||
[property: Required] string Version,
|
||||
[property: Required] string ArtifactContentType,
|
||||
[property: Required] string ArtifactFileName,
|
||||
[property: Required] string ArtifactContentBase64,
|
||||
IReadOnlyDictionary<string, string?>? Metadata);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Response returned after callgraph ingestion.
|
||||
/// </summary>
|
||||
public sealed record CallgraphIngestResponse(
|
||||
string CallgraphId,
|
||||
string ArtifactPath,
|
||||
string ArtifactHash);
|
||||
12
src/Signals/StellaOps.Signals/Models/CallgraphNode.cs
Normal file
12
src/Signals/StellaOps.Signals/Models/CallgraphNode.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Signals.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Normalized callgraph node.
|
||||
/// </summary>
|
||||
public sealed record CallgraphNode(
|
||||
string Id,
|
||||
string Name,
|
||||
string Kind,
|
||||
string? Namespace,
|
||||
string? File,
|
||||
int? Line);
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Artifact storage configuration for Signals callgraph ingestion.
|
||||
/// </summary>
|
||||
public sealed class SignalsArtifactStorageOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Root directory used to persist raw callgraph artifacts.
|
||||
/// </summary>
|
||||
public string RootPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "callgraph-artifacts");
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configured values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(RootPath))
|
||||
{
|
||||
throw new InvalidOperationException("Signals artifact storage path must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/Signals/StellaOps.Signals/Options/SignalsAuthorityOptions.cs
Normal file
101
src/Signals/StellaOps.Signals/Options/SignalsAuthorityOptions.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Authority configuration for the Signals service.
|
||||
/// </summary>
|
||||
public sealed class SignalsAuthorityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables Authority-backed authentication.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allows header-based development fallback when Authority is disabled.
|
||||
/// </summary>
|
||||
public bool AllowAnonymousFallback { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Authority issuer URL.
|
||||
/// </summary>
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether HTTPS metadata is required.
|
||||
/// </summary>
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata address override.
|
||||
/// </summary>
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Back-channel timeout (seconds).
|
||||
/// </summary>
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Token clock skew allowance (seconds).
|
||||
/// </summary>
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Accepted token audiences.
|
||||
/// </summary>
|
||||
public IList<string> Audiences { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Required scopes.
|
||||
/// </summary>
|
||||
public IList<string> RequiredScopes { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Required tenants.
|
||||
/// </summary>
|
||||
public IList<string> RequiredTenants { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Networks allowed to bypass scope enforcement.
|
||||
/// </summary>
|
||||
public IList<string> BypassNetworks { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configured options.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("Signals Authority issuer must be configured when Authority integration is enabled.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(Issuer.Trim(), UriKind.Absolute, out var issuerUri))
|
||||
{
|
||||
throw new InvalidOperationException("Signals Authority issuer must be an absolute URI.");
|
||||
}
|
||||
|
||||
if (RequireHttpsMetadata && !issuerUri.IsLoopback && !string.Equals(issuerUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Signals Authority issuer must use HTTPS unless running on loopback.");
|
||||
}
|
||||
|
||||
if (BackchannelTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Signals Authority back-channel timeout must be greater than zero seconds.");
|
||||
}
|
||||
|
||||
if (TokenClockSkewSeconds < 0 || TokenClockSkewSeconds > 300)
|
||||
{
|
||||
throw new InvalidOperationException("Signals Authority token clock skew must be between 0 and 300 seconds.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using StellaOps.Signals.Routing;
|
||||
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Applies Signals-specific defaults to <see cref="SignalsAuthorityOptions"/>.
|
||||
/// </summary>
|
||||
internal static class SignalsAuthorityOptionsConfigurator
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures required defaults are populated.
|
||||
/// </summary>
|
||||
public static void ApplyDefaults(SignalsAuthorityOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (!options.Audiences.Any())
|
||||
{
|
||||
options.Audiences.Add("api://signals");
|
||||
}
|
||||
|
||||
EnsureScope(options, SignalsPolicies.Read);
|
||||
EnsureScope(options, SignalsPolicies.Write);
|
||||
EnsureScope(options, SignalsPolicies.Admin);
|
||||
}
|
||||
|
||||
private static void EnsureScope(SignalsAuthorityOptions options, string scope)
|
||||
{
|
||||
if (options.RequiredScopes.Any(existing => string.Equals(existing, scope, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.RequiredScopes.Add(scope);
|
||||
}
|
||||
}
|
||||
45
src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs
Normal file
45
src/Signals/StellaOps.Signals/Options/SignalsMongoOptions.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB configuration for Signals.
|
||||
/// </summary>
|
||||
public sealed class SignalsMongoOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// MongoDB connection string.
|
||||
/// </summary>
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Database name to use when the connection string omits one.
|
||||
/// </summary>
|
||||
public string Database { get; set; } = "signals";
|
||||
|
||||
/// <summary>
|
||||
/// Collection name storing normalized callgraphs.
|
||||
/// </summary>
|
||||
public string CallgraphsCollection { get; set; } = "callgraphs";
|
||||
|
||||
/// <summary>
|
||||
/// Validates the configured values.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("Signals Mongo connection string must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Database))
|
||||
{
|
||||
throw new InvalidOperationException("Signals Mongo database name must be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(CallgraphsCollection))
|
||||
{
|
||||
throw new InvalidOperationException("Signals callgraph collection name must be configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Signals/StellaOps.Signals/Options/SignalsOptions.cs
Normal file
37
src/Signals/StellaOps.Signals/Options/SignalsOptions.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace StellaOps.Signals.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Root configuration for the Signals service.
|
||||
/// </summary>
|
||||
public sealed class SignalsOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Signals";
|
||||
|
||||
/// <summary>
|
||||
/// Authority integration settings.
|
||||
/// </summary>
|
||||
public SignalsAuthorityOptions Authority { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB configuration.
|
||||
/// </summary>
|
||||
public SignalsMongoOptions Mongo { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Artifact storage configuration.
|
||||
/// </summary>
|
||||
public SignalsArtifactStorageOptions Storage { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Validates configured options.
|
||||
/// </summary>
|
||||
public void Validate()
|
||||
{
|
||||
Authority.Validate();
|
||||
Mongo.Validate();
|
||||
Storage.Validate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Result produced by a callgraph parser.
|
||||
/// </summary>
|
||||
public sealed record CallgraphParseResult(
|
||||
IReadOnlyList<CallgraphNode> Nodes,
|
||||
IReadOnlyList<CallgraphEdge> Edges,
|
||||
string FormatVersion);
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a parser is not registered for the requested language.
|
||||
/// </summary>
|
||||
public sealed class CallgraphParserNotFoundException : Exception
|
||||
{
|
||||
public CallgraphParserNotFoundException(string language)
|
||||
: base($"No callgraph parser registered for language '{language}'.")
|
||||
{
|
||||
Language = language;
|
||||
}
|
||||
|
||||
public string Language { get; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a callgraph artifact is invalid.
|
||||
/// </summary>
|
||||
public sealed class CallgraphParserValidationException : Exception
|
||||
{
|
||||
public CallgraphParserValidationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
21
src/Signals/StellaOps.Signals/Parsing/ICallgraphParser.cs
Normal file
21
src/Signals/StellaOps.Signals/Parsing/ICallgraphParser.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Parses raw callgraph artifacts into normalized structures.
|
||||
/// </summary>
|
||||
public interface ICallgraphParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Language identifier handled by the parser (e.g., java, nodejs).
|
||||
/// </summary>
|
||||
string Language { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses the supplied artifact stream.
|
||||
/// </summary>
|
||||
Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves callgraph parsers for specific languages.
|
||||
/// </summary>
|
||||
public interface ICallgraphParserResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a parser for the supplied language.
|
||||
/// </summary>
|
||||
ICallgraphParser Resolve(string language);
|
||||
}
|
||||
|
||||
internal sealed class CallgraphParserResolver : ICallgraphParserResolver
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, ICallgraphParser> parsersByLanguage;
|
||||
|
||||
public CallgraphParserResolver(IEnumerable<ICallgraphParser> parsers)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parsers);
|
||||
|
||||
var map = new Dictionary<string, ICallgraphParser>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var parser in parsers)
|
||||
{
|
||||
map[parser.Language] = parser;
|
||||
}
|
||||
|
||||
parsersByLanguage = map;
|
||||
}
|
||||
|
||||
public ICallgraphParser Resolve(string language)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
|
||||
if (parsersByLanguage.TryGetValue(language, out var parser))
|
||||
{
|
||||
return parser;
|
||||
}
|
||||
|
||||
throw new CallgraphParserNotFoundException(language);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Simple JSON-based callgraph parser used for initial language coverage.
|
||||
/// </summary>
|
||||
internal sealed class SimpleJsonCallgraphParser : ICallgraphParser
|
||||
{
|
||||
private readonly JsonSerializerOptions serializerOptions;
|
||||
|
||||
public SimpleJsonCallgraphParser(string language)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(language);
|
||||
Language = language;
|
||||
serializerOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
public string Language { get; }
|
||||
|
||||
public async Task<CallgraphParseResult> ParseAsync(Stream artifactStream, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(artifactStream);
|
||||
|
||||
var payload = await JsonSerializer.DeserializeAsync<RawCallgraphPayload>(
|
||||
artifactStream,
|
||||
serializerOptions,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (payload is null)
|
||||
{
|
||||
throw new CallgraphParserValidationException("Callgraph artifact payload is empty.");
|
||||
}
|
||||
|
||||
if (payload.Graph is null)
|
||||
{
|
||||
throw new CallgraphParserValidationException("Callgraph artifact is missing 'graph' section.");
|
||||
}
|
||||
|
||||
if (payload.Graph.Nodes is null || payload.Graph.Nodes.Count == 0)
|
||||
{
|
||||
throw new CallgraphParserValidationException("Callgraph artifact must include at least one node.");
|
||||
}
|
||||
|
||||
if (payload.Graph.Edges is null)
|
||||
{
|
||||
payload.Graph.Edges = new List<RawCallgraphEdge>();
|
||||
}
|
||||
|
||||
var nodes = new List<CallgraphNode>(payload.Graph.Nodes.Count);
|
||||
foreach (var node in payload.Graph.Nodes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.Id))
|
||||
{
|
||||
throw new CallgraphParserValidationException("Callgraph node is missing an id.");
|
||||
}
|
||||
|
||||
nodes.Add(new CallgraphNode(
|
||||
Id: node.Id.Trim(),
|
||||
Name: node.Name ?? node.Id.Trim(),
|
||||
Kind: node.Kind ?? "function",
|
||||
Namespace: node.Namespace,
|
||||
File: node.File,
|
||||
Line: node.Line));
|
||||
}
|
||||
|
||||
var edges = new List<CallgraphEdge>(payload.Graph.Edges.Count);
|
||||
foreach (var edge in payload.Graph.Edges)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.Source) || string.IsNullOrWhiteSpace(edge.Target))
|
||||
{
|
||||
throw new CallgraphParserValidationException("Callgraph edge requires both source and target.");
|
||||
}
|
||||
|
||||
edges.Add(new CallgraphEdge(edge.Source.Trim(), edge.Target.Trim(), edge.Type ?? "call"));
|
||||
}
|
||||
|
||||
var formatVersion = string.IsNullOrWhiteSpace(payload.FormatVersion) ? "1.0" : payload.FormatVersion.Trim();
|
||||
return new CallgraphParseResult(nodes, edges, formatVersion);
|
||||
}
|
||||
|
||||
private sealed class RawCallgraphPayload
|
||||
{
|
||||
public string? FormatVersion { get; set; }
|
||||
public RawCallgraphGraph? Graph { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawCallgraphGraph
|
||||
{
|
||||
public List<RawCallgraphNode>? Nodes { get; set; }
|
||||
public List<RawCallgraphEdge>? Edges { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawCallgraphNode
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? Namespace { get; set; }
|
||||
public string? File { get; set; }
|
||||
public int? Line { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RawCallgraphEdge
|
||||
{
|
||||
public string? Source { get; set; }
|
||||
public string? Target { get; set; }
|
||||
public string? Type { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Persists normalized callgraphs.
|
||||
/// </summary>
|
||||
public interface ICallgraphRepository
|
||||
{
|
||||
Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Persistence;
|
||||
|
||||
internal sealed class MongoCallgraphRepository : ICallgraphRepository
|
||||
{
|
||||
private readonly IMongoCollection<CallgraphDocument> collection;
|
||||
private readonly ILogger<MongoCallgraphRepository> logger;
|
||||
|
||||
public MongoCallgraphRepository(IMongoCollection<CallgraphDocument> collection, ILogger<MongoCallgraphRepository> logger)
|
||||
{
|
||||
this.collection = collection ?? throw new ArgumentNullException(nameof(collection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CallgraphDocument> UpsertAsync(CallgraphDocument document, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var filter = Builders<CallgraphDocument>.Filter.Eq(d => d.Component, document.Component)
|
||||
& Builders<CallgraphDocument>.Filter.Eq(d => d.Version, document.Version)
|
||||
& Builders<CallgraphDocument>.Filter.Eq(d => d.Language, document.Language);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(document.Id))
|
||||
{
|
||||
document.Id = ObjectId.GenerateNewId().ToString();
|
||||
}
|
||||
|
||||
document.IngestedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var options = new ReplaceOptions { IsUpsert = true };
|
||||
var result = await collection.ReplaceOneAsync(filter, document, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.UpsertedId != null)
|
||||
{
|
||||
document.Id = result.UpsertedId.AsObjectId.ToString();
|
||||
}
|
||||
|
||||
logger.LogInformation("Upserted callgraph {Language}:{Component}:{Version} (id={Id}).", document.Language, document.Component, document.Version, document.Id);
|
||||
return document;
|
||||
}
|
||||
}
|
||||
313
src/Signals/StellaOps.Signals/Program.cs
Normal file
313
src/Signals/StellaOps.Signals/Program.cs
Normal file
@@ -0,0 +1,313 @@
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using NetEscapades.Configuration.Yaml;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Signals.Authentication;
|
||||
using StellaOps.Signals.Hosting;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Routing;
|
||||
using StellaOps.Signals.Services;
|
||||
using StellaOps.Signals.Storage;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "SIGNALS_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
var contentRoot = builder.Environment.ContentRootPath;
|
||||
foreach (var relative in new[]
|
||||
{
|
||||
"../etc/signals.yaml",
|
||||
"../etc/signals.local.yaml",
|
||||
"signals.yaml",
|
||||
"signals.local.yaml"
|
||||
})
|
||||
{
|
||||
var path = Path.Combine(contentRoot, relative);
|
||||
configurationBuilder.AddYamlFile(path, optional: true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
var bootstrap = builder.Configuration.BindOptions<SignalsOptions>(
|
||||
SignalsOptions.SectionName,
|
||||
static (options, _) =>
|
||||
{
|
||||
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
|
||||
options.Validate();
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<SignalsOptions>()
|
||||
.Bind(builder.Configuration.GetSection(SignalsOptions.SectionName))
|
||||
.PostConfigure(static options =>
|
||||
{
|
||||
SignalsAuthorityOptionsConfigurator.ApplyDefaults(options.Authority);
|
||||
options.Validate();
|
||||
})
|
||||
.Validate(static options =>
|
||||
{
|
||||
try
|
||||
{
|
||||
options.Validate();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new OptionsValidationException(
|
||||
SignalsOptions.SectionName,
|
||||
typeof(SignalsOptions),
|
||||
new[] { ex.Message });
|
||||
}
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IOptions<SignalsOptions>>().Value);
|
||||
builder.Services.AddSingleton<SignalsStartupState>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
|
||||
builder.Services.AddSingleton<IMongoClient>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
||||
return new MongoClient(opts.Mongo.ConnectionString);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IMongoDatabase>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
||||
var mongoClient = sp.GetRequiredService<IMongoClient>();
|
||||
var mongoUrl = MongoUrl.Create(opts.Mongo.ConnectionString);
|
||||
var databaseName = string.IsNullOrWhiteSpace(mongoUrl.DatabaseName) ? opts.Mongo.Database : mongoUrl.DatabaseName;
|
||||
return mongoClient.GetDatabase(databaseName);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IMongoCollection<CallgraphDocument>>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<SignalsOptions>>().Value;
|
||||
var database = sp.GetRequiredService<IMongoDatabase>();
|
||||
var collection = database.GetCollection<CallgraphDocument>(opts.Mongo.CallgraphsCollection);
|
||||
EnsureCallgraphIndexes(collection);
|
||||
return collection;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<ICallgraphRepository, MongoCallgraphRepository>();
|
||||
builder.Services.AddSingleton<ICallgraphArtifactStore, FileSystemCallgraphArtifactStore>();
|
||||
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("java"));
|
||||
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("nodejs"));
|
||||
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("python"));
|
||||
builder.Services.AddSingleton<ICallgraphParser>(new SimpleJsonCallgraphParser("go"));
|
||||
builder.Services.AddSingleton<ICallgraphParserResolver, CallgraphParserResolver>();
|
||||
builder.Services.AddSingleton<ICallgraphIngestionService, CallgraphIngestionService>();
|
||||
|
||||
if (bootstrap.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsScopeHandler();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(SignalsPolicies.Read, SignalsPolicies.Read);
|
||||
options.AddStellaOpsScopePolicy(SignalsPolicies.Write, SignalsPolicies.Write);
|
||||
options.AddStellaOpsScopePolicy(SignalsPolicies.Admin, SignalsPolicies.Admin);
|
||||
});
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: $"{SignalsOptions.SectionName}:Authority",
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = bootstrap.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = bootstrap.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = bootstrap.Authority.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(bootstrap.Authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(bootstrap.Authority.TokenClockSkewSeconds);
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in bootstrap.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
resourceOptions.RequiredScopes.Clear();
|
||||
foreach (var scope in bootstrap.Authority.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in bootstrap.Authority.RequiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in bootstrap.Authority.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!bootstrap.Authority.Enabled)
|
||||
{
|
||||
app.Logger.LogWarning("Signals Authority authentication is disabled; relying on header-based development fallback.");
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/healthz").AllowAnonymous();
|
||||
app.MapGet("/readyz", static (SignalsStartupState state) =>
|
||||
state.IsReady ? Results.Ok(new { status = "ready" }) : Results.StatusCode(StatusCodes.Status503ServiceUnavailable))
|
||||
.AllowAnonymous();
|
||||
|
||||
var fallbackAllowed = !bootstrap.Authority.Enabled || bootstrap.Authority.AllowAnonymousFallback;
|
||||
|
||||
var signalsGroup = app.MapGroup("/signals");
|
||||
|
||||
signalsGroup.MapGet("/ping", (HttpContext context, SignalsOptions options) =>
|
||||
Program.TryAuthorize(context, requiredScope: SignalsPolicies.Read, fallbackAllowed: options.Authority.AllowAnonymousFallback, out var failure)
|
||||
? Results.NoContent()
|
||||
: failure ?? Results.Unauthorized()).WithName("SignalsPing");
|
||||
|
||||
signalsGroup.MapGet("/status", (HttpContext context, SignalsOptions options) =>
|
||||
Program.TryAuthorize(context, SignalsPolicies.Read, options.Authority.AllowAnonymousFallback, out var failure)
|
||||
? Results.Ok(new
|
||||
{
|
||||
service = "signals",
|
||||
version = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown"
|
||||
})
|
||||
: failure ?? Results.Unauthorized()).WithName("SignalsStatus");
|
||||
|
||||
signalsGroup.MapPost("/callgraphs", async Task<IResult> (
|
||||
HttpContext context,
|
||||
SignalsOptions options,
|
||||
CallgraphIngestRequest request,
|
||||
ICallgraphIngestionService ingestionService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var failure))
|
||||
{
|
||||
return failure ?? Results.Unauthorized();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await ingestionService.IngestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted($"/signals/callgraphs/{result.CallgraphId}", result);
|
||||
}
|
||||
catch (CallgraphIngestionValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (CallgraphParserNotFoundException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (CallgraphParserValidationException ex)
|
||||
{
|
||||
return Results.UnprocessableEntity(new { error = ex.Message });
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).WithName("SignalsCallgraphIngest");
|
||||
|
||||
signalsGroup.MapPost("/runtime-facts", (HttpContext context, SignalsOptions options) =>
|
||||
Program.TryAuthorize(context, SignalsPolicies.Write, options.Authority.AllowAnonymousFallback, out var failure)
|
||||
? Results.StatusCode(StatusCodes.Status501NotImplemented)
|
||||
: failure ?? Results.Unauthorized()).WithName("SignalsRuntimeIngest");
|
||||
|
||||
signalsGroup.MapPost("/reachability/recompute", (HttpContext context, SignalsOptions options) =>
|
||||
Program.TryAuthorize(context, SignalsPolicies.Admin, options.Authority.AllowAnonymousFallback, out var failure)
|
||||
? Results.StatusCode(StatusCodes.Status501NotImplemented)
|
||||
: failure ?? Results.Unauthorized()).WithName("SignalsReachabilityRecompute");
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program
|
||||
{
|
||||
internal static bool TryAuthorize(HttpContext httpContext, string requiredScope, bool fallbackAllowed, out IResult? failure)
|
||||
{
|
||||
if (httpContext.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
if (TokenScopeAuthorizer.HasScope(httpContext.User, requiredScope))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!fallbackAllowed)
|
||||
{
|
||||
failure = Results.Unauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!httpContext.Request.Headers.TryGetValue("X-Scopes", out var scopesHeader) ||
|
||||
string.IsNullOrWhiteSpace(scopesHeader.ToString()))
|
||||
{
|
||||
failure = Results.Unauthorized();
|
||||
return false;
|
||||
}
|
||||
|
||||
var principal = HeaderScopeAuthorizer.CreatePrincipal(scopesHeader.ToString());
|
||||
if (HeaderScopeAuthorizer.HasScope(principal, requiredScope))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void EnsureCallgraphIndexes(IMongoCollection<CallgraphDocument> collection)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(collection);
|
||||
|
||||
try
|
||||
{
|
||||
var indexKeys = Builders<CallgraphDocument>.IndexKeys
|
||||
.Ascending(document => document.Component)
|
||||
.Ascending(document => document.Version)
|
||||
.Ascending(document => document.Language);
|
||||
|
||||
var model = new CreateIndexModel<CallgraphDocument>(indexKeys, new CreateIndexOptions
|
||||
{
|
||||
Name = "callgraphs_component_version_language_unique",
|
||||
Unique = true
|
||||
});
|
||||
|
||||
collection.Indexes.CreateOne(model);
|
||||
}
|
||||
catch (MongoCommandException ex) when (string.Equals(ex.CodeName, "IndexOptionsConflict", StringComparison.Ordinal))
|
||||
{
|
||||
// Index already exists with different options – ignore to keep startup idempotent.
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/Signals/StellaOps.Signals/Routing/SignalsPolicies.cs
Normal file
22
src/Signals/StellaOps.Signals/Routing/SignalsPolicies.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace StellaOps.Signals.Routing;
|
||||
|
||||
/// <summary>
|
||||
/// Signals service authorization policy names and scope constants.
|
||||
/// </summary>
|
||||
public static class SignalsPolicies
|
||||
{
|
||||
/// <summary>
|
||||
/// Scope required for read operations.
|
||||
/// </summary>
|
||||
public const string Read = "signals:read";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required for write operations.
|
||||
/// </summary>
|
||||
public const string Write = "signals:write";
|
||||
|
||||
/// <summary>
|
||||
/// Scope required for administrative operations.
|
||||
/// </summary>
|
||||
public const string Admin = "signals:admin";
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Models;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Parsing;
|
||||
using StellaOps.Signals.Persistence;
|
||||
using StellaOps.Signals.Storage;
|
||||
using StellaOps.Signals.Storage.Models;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
internal sealed class CallgraphIngestionService : ICallgraphIngestionService
|
||||
{
|
||||
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"application/json",
|
||||
"application/vnd.stellaops.callgraph+json"
|
||||
};
|
||||
|
||||
private readonly ICallgraphParserResolver parserResolver;
|
||||
private readonly ICallgraphArtifactStore artifactStore;
|
||||
private readonly ICallgraphRepository repository;
|
||||
private readonly ILogger<CallgraphIngestionService> logger;
|
||||
private readonly SignalsOptions options;
|
||||
private readonly TimeProvider timeProvider;
|
||||
|
||||
public CallgraphIngestionService(
|
||||
ICallgraphParserResolver parserResolver,
|
||||
ICallgraphArtifactStore artifactStore,
|
||||
ICallgraphRepository repository,
|
||||
IOptions<SignalsOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CallgraphIngestionService> logger)
|
||||
{
|
||||
this.parserResolver = parserResolver ?? throw new ArgumentNullException(nameof(parserResolver));
|
||||
this.artifactStore = artifactStore ?? throw new ArgumentNullException(nameof(artifactStore));
|
||||
this.repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
public async Task<CallgraphIngestResponse> IngestAsync(CallgraphIngestRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateRequest(request);
|
||||
|
||||
var parser = parserResolver.Resolve(request.Language);
|
||||
|
||||
var artifactBytes = Convert.FromBase64String(request.ArtifactContentBase64);
|
||||
await using var parseStream = new MemoryStream(artifactBytes, writable: false);
|
||||
var parseResult = await parser.ParseAsync(parseStream, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
parseStream.Position = 0;
|
||||
var hash = ComputeSha256(artifactBytes);
|
||||
|
||||
var artifactMetadata = await artifactStore.SaveAsync(
|
||||
new CallgraphArtifactSaveRequest(
|
||||
request.Language,
|
||||
request.Component,
|
||||
request.Version,
|
||||
request.ArtifactFileName,
|
||||
request.ArtifactContentType,
|
||||
hash),
|
||||
parseStream,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var document = new CallgraphDocument
|
||||
{
|
||||
Language = parser.Language,
|
||||
Component = request.Component,
|
||||
Version = request.Version,
|
||||
Nodes = new List<CallgraphNode>(parseResult.Nodes),
|
||||
Edges = new List<CallgraphEdge>(parseResult.Edges),
|
||||
Metadata = request.Metadata is null
|
||||
? null
|
||||
: new Dictionary<string, string?>(request.Metadata, StringComparer.OrdinalIgnoreCase),
|
||||
Artifact = new CallgraphArtifactMetadata
|
||||
{
|
||||
Path = artifactMetadata.Path,
|
||||
Hash = artifactMetadata.Hash,
|
||||
ContentType = artifactMetadata.ContentType,
|
||||
Length = artifactMetadata.Length
|
||||
},
|
||||
IngestedAt = timeProvider.GetUtcNow()
|
||||
};
|
||||
|
||||
document.Metadata ??= new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
document.Metadata["formatVersion"] = parseResult.FormatVersion;
|
||||
|
||||
document = await repository.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
logger.LogInformation(
|
||||
"Ingested callgraph {Language}:{Component}:{Version} (id={Id}) with {NodeCount} nodes and {EdgeCount} edges.",
|
||||
document.Language,
|
||||
document.Component,
|
||||
document.Version,
|
||||
document.Id,
|
||||
document.Nodes.Count,
|
||||
document.Edges.Count);
|
||||
|
||||
return new CallgraphIngestResponse(document.Id, document.Artifact.Path, document.Artifact.Hash);
|
||||
}
|
||||
|
||||
private static void ValidateRequest(CallgraphIngestRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Language))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException("Language is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Component))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException("Component is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Version))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException("Version is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactContentBase64))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException("Artifact content is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactFileName))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException("Artifact file name is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactContentType) || !AllowedContentTypes.Contains(request.ArtifactContentType))
|
||||
{
|
||||
throw new CallgraphIngestionValidationException($"Unsupported artifact content type '{request.ArtifactContentType}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
Span<byte> hash = stackalloc byte[SHA256.HashSizeInBytes];
|
||||
SHA256.HashData(buffer, hash);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when the ingestion request is invalid.
|
||||
/// </summary>
|
||||
public sealed class CallgraphIngestionValidationException : Exception
|
||||
{
|
||||
public CallgraphIngestionValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Models;
|
||||
|
||||
namespace StellaOps.Signals.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles ingestion of callgraph artifacts.
|
||||
/// </summary>
|
||||
public interface ICallgraphIngestionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Ingests the supplied callgraph request.
|
||||
/// </summary>
|
||||
Task<CallgraphIngestResponse> IngestAsync(CallgraphIngestRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
21
src/Signals/StellaOps.Signals/StellaOps.Signals.csproj
Normal file
21
src/Signals/StellaOps.Signals/StellaOps.Signals.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Signals.Options;
|
||||
using StellaOps.Signals.Storage.Models;
|
||||
|
||||
namespace StellaOps.Signals.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Stores callgraph artifacts on the local filesystem.
|
||||
/// </summary>
|
||||
internal sealed class FileSystemCallgraphArtifactStore : ICallgraphArtifactStore
|
||||
{
|
||||
private readonly SignalsArtifactStorageOptions storageOptions;
|
||||
private readonly ILogger<FileSystemCallgraphArtifactStore> logger;
|
||||
|
||||
public FileSystemCallgraphArtifactStore(IOptions<SignalsOptions> options, ILogger<FileSystemCallgraphArtifactStore> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
storageOptions = options.Value.Storage;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<StoredCallgraphArtifact> SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
var root = storageOptions.RootPath;
|
||||
var directory = Path.Combine(root, Sanitize(request.Language), Sanitize(request.Component), Sanitize(request.Version));
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(request.FileName)
|
||||
? FormattableString.Invariant($"artifact-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.bin")
|
||||
: request.FileName.Trim();
|
||||
|
||||
var destinationPath = Path.Combine(directory, fileName);
|
||||
|
||||
await using (var fileStream = File.Create(destinationPath))
|
||||
{
|
||||
await content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(destinationPath);
|
||||
logger.LogInformation("Stored callgraph artifact at {Path} (length={Length}).", destinationPath, fileInfo.Length);
|
||||
|
||||
return new StoredCallgraphArtifact(
|
||||
Path.GetRelativePath(root, destinationPath),
|
||||
fileInfo.Length,
|
||||
request.Hash,
|
||||
request.ContentType);
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
=> string.Join('_', value.Split(Path.GetInvalidFileNameChars(), StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Signals.Storage.Models;
|
||||
|
||||
namespace StellaOps.Signals.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Persists raw callgraph artifacts.
|
||||
/// </summary>
|
||||
public interface ICallgraphArtifactStore
|
||||
{
|
||||
Task<StoredCallgraphArtifact> SaveAsync(CallgraphArtifactSaveRequest request, Stream content, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Signals.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Context required to persist a callgraph artifact.
|
||||
/// </summary>
|
||||
public sealed record CallgraphArtifactSaveRequest(
|
||||
string Language,
|
||||
string Component,
|
||||
string Version,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
string Hash);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StellaOps.Signals.Storage.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result returned after storing an artifact.
|
||||
/// </summary>
|
||||
public sealed record StoredCallgraphArtifact(
|
||||
string Path,
|
||||
long Length,
|
||||
string Hash,
|
||||
string ContentType);
|
||||
13
src/Signals/StellaOps.Signals/TASKS.md
Normal file
13
src/Signals/StellaOps.Signals/TASKS.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Signals Service Task Board — Reachability v1
|
||||
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|
||||
|----|--------|----------|------------|-------------|---------------|
|
||||
| SIGNALS-24-001 | DONE (2025-10-29) | Signals Guild, Architecture Guild | SBOM-GRAPH-24-002 | Implement Signals API skeleton (ASP.NET Minimal API) with auth middleware, health checks, and configuration binding. | Service boots with configuration validation, `/healthz`/`/readyz` return 200, RBAC enforced in integration tests. |
|
||||
> 2025-10-29: Skeleton live with scope policies, stub endpoints, integration tests. Sample config added under `etc/signals.yaml.sample`.
|
||||
| SIGNALS-24-002 | DONE (2025-10-29) | Signals Guild, Language Specialists | SIGNALS-24-001 | Build callgraph ingestion pipeline (Java/Node/Python/Go parsers) normalizing into `callgraphs` collection and storing artifact metadata in object storage. | Parsers accept sample artifacts; data persisted with schema validation; unit tests cover malformed inputs. |
|
||||
> 2025-10-29: JSON parsers for java/nodejs/python/go implemented; artifacts stored on filesystem with SHA-256, callgraphs upserted into Mongo with unique index; integration tests cover success + malformed requests.
|
||||
| SIGNALS-24-003 | BLOCKED (2025-10-27) | Signals Guild, Runtime Guild | SIGNALS-24-001 | Implement runtime facts ingestion endpoint and normalizer (process, sockets, container metadata) populating `context_facts` with AOC provenance. | Endpoint ingests fixture batches; duplicates deduped; schema enforced; tests cover privacy filters. |
|
||||
> 2025-10-27: Depends on SIGNALS-24-001 for base API host + authentication plumbing.
|
||||
| SIGNALS-24-004 | BLOCKED (2025-10-27) | Signals Guild, Data Science | SIGNALS-24-002, SIGNALS-24-003 | Deliver reachability scoring engine producing states/scores and writing to `reachability_facts`; expose configuration for weights. | Scoring engine deterministic; tests cover state transitions; metrics emitted. |
|
||||
> 2025-10-27: Upstream ingestion pipelines (SIGNALS-24-002/003) blocked; scoring engine cannot proceed.
|
||||
| SIGNALS-24-005 | BLOCKED (2025-10-27) | Signals Guild, Platform Events Guild | SIGNALS-24-004 | Implement Redis caches (`reachability_cache:*`), invalidation on new facts, and publish `signals.fact.updated` events. | Cache hit rate tracked; invalidations working; events delivered with idempotent ids; integration tests pass. |
|
||||
> 2025-10-27: Awaiting scoring engine and ingestion layers before wiring cache/events.
|
||||
Reference in New Issue
Block a user