Restructure solution layout by module
This commit is contained in:
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user