Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View 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

View 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.

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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;
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -0,0 +1,9 @@
namespace StellaOps.Signals.Models;
/// <summary>
/// Normalized callgraph edge.
/// </summary>
public sealed record CallgraphEdge(
string SourceId,
string TargetId,
string Type);

View File

@@ -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);

View File

@@ -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);

View 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);

View File

@@ -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.");
}
}
}

View 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.");
}
}
}

View File

@@ -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);
}
}

View 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.");
}
}
}

View 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();
}
}

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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)
{
}
}

View 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);
}

View File

@@ -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);
}
}

View File

@@ -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; }
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View 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.
}
}
}

View 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";
}

View File

@@ -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)
{
}
}

View File

@@ -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);
}

View 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>

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View 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.