stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -34,3 +34,8 @@ Define and deliver the Platform Service that aggregates cross-service views for
|
||||
- Update sprint status in `docs/implplan/SPRINT_*.md` when starting/stopping work.
|
||||
- Document cross-module contract changes in sprint Decisions & Risks.
|
||||
- Avoid non-deterministic data ordering or timestamps in responses.
|
||||
|
||||
## Service Endpoints
|
||||
- Development: https://localhost:10010, http://localhost:10011
|
||||
- Local alias: https://platform.stella-ops.local, http://platform.stella-ops.local
|
||||
- Env var: STELLAOPS_PLATFORM_URL
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Post-configures <see cref="PlatformServiceOptions"/> by scanning environment variables
|
||||
/// matching <c>STELLAOPS_*_URL</c> and mapping them into <c>EnvironmentSettings.ApiBaseUrls</c>.
|
||||
/// <para>
|
||||
/// This is Layer 1 (lowest priority). Values are only set for keys NOT already present
|
||||
/// in the options (i.e. YAML/JSON config wins over env vars).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class StellaOpsEnvVarPostConfigure : IPostConfigureOptions<PlatformServiceOptions>
|
||||
{
|
||||
private static readonly Regex EnvVarPattern = new(
|
||||
@"^STELLAOPS_(.+)_URL$",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public void PostConfigure(string? name, PlatformServiceOptions options)
|
||||
{
|
||||
var envSettings = options.EnvironmentSettings;
|
||||
var envVars = Environment.GetEnvironmentVariables();
|
||||
|
||||
foreach (var key in envVars.Keys)
|
||||
{
|
||||
if (key is not string keyStr)
|
||||
continue;
|
||||
|
||||
var match = EnvVarPattern.Match(keyStr);
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
var value = envVars[key] as string;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
|
||||
// STELLAOPS_SCANNER_URL -> "scanner"
|
||||
// STELLAOPS_POLICY_ENGINE_URL -> "policyEngine"
|
||||
var serviceName = NormalizeServiceName(match.Groups[1].Value);
|
||||
|
||||
// Only set if not already present (YAML wins over env vars)
|
||||
if (!envSettings.ApiBaseUrls.ContainsKey(serviceName))
|
||||
{
|
||||
envSettings.ApiBaseUrls[serviceName] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an env var segment like <c>POLICY_ENGINE</c> to camelCase <c>policyEngine</c>.
|
||||
/// </summary>
|
||||
internal static string NormalizeServiceName(string envSegment)
|
||||
{
|
||||
var parts = envSegment.Split('_', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length == 0)
|
||||
return envSegment.ToLowerInvariant();
|
||||
|
||||
// First part is all lowercase, subsequent parts are PascalCase
|
||||
var result = parts[0].ToLowerInvariant();
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
if (part.Length == 0) continue;
|
||||
result += char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Environment settings response served at <c>/platform/envsettings.json</c>.
|
||||
/// Schema matches the Angular <c>AppConfig</c> interface so the frontend can
|
||||
/// consume the payload without transformation.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentSettingsResponse
|
||||
{
|
||||
[JsonPropertyName("authority")]
|
||||
public required EnvironmentAuthoritySettings Authority { get; init; }
|
||||
|
||||
[JsonPropertyName("apiBaseUrls")]
|
||||
public required Dictionary<string, string> ApiBaseUrls { get; init; }
|
||||
|
||||
[JsonPropertyName("telemetry")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EnvironmentTelemetrySettings? Telemetry { get; init; }
|
||||
|
||||
[JsonPropertyName("welcome")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EnvironmentWelcomeSettings? Welcome { get; init; }
|
||||
|
||||
[JsonPropertyName("doctor")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EnvironmentDoctorSettings? Doctor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Setup state indicator for the frontend setup wizard.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>null</c> (absent from JSON) — setup required (fresh install or no DB)</item>
|
||||
/// <item><c>"complete"</c> — setup done, proceed normally</item>
|
||||
/// <item>Any other value — a <c>SetupStepId</c> indicating setup in progress; resume at that step</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[JsonPropertyName("setup")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Setup { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EnvironmentAuthoritySettings
|
||||
{
|
||||
[JsonPropertyName("issuer")]
|
||||
public required string Issuer { get; init; }
|
||||
|
||||
[JsonPropertyName("clientId")]
|
||||
public required string ClientId { get; init; }
|
||||
|
||||
[JsonPropertyName("authorizeEndpoint")]
|
||||
public required string AuthorizeEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("tokenEndpoint")]
|
||||
public required string TokenEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("logoutEndpoint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? LogoutEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("redirectUri")]
|
||||
public required string RedirectUri { get; init; }
|
||||
|
||||
[JsonPropertyName("postLogoutRedirectUri")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? PostLogoutRedirectUri { get; init; }
|
||||
|
||||
[JsonPropertyName("scope")]
|
||||
public required string Scope { get; init; }
|
||||
|
||||
[JsonPropertyName("audience")]
|
||||
public required string Audience { get; init; }
|
||||
|
||||
[JsonPropertyName("dpopAlgorithms")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? DpopAlgorithms { get; init; }
|
||||
|
||||
[JsonPropertyName("refreshLeewaySeconds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int RefreshLeewaySeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EnvironmentTelemetrySettings
|
||||
{
|
||||
[JsonPropertyName("otlpEndpoint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? OtlpEndpoint { get; init; }
|
||||
|
||||
[JsonPropertyName("sampleRate")]
|
||||
public double SampleRate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EnvironmentWelcomeSettings
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Title { get; init; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Message { get; init; }
|
||||
|
||||
[JsonPropertyName("docsUrl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? DocsUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed class EnvironmentDoctorSettings
|
||||
{
|
||||
[JsonPropertyName("fixEnabled")]
|
||||
public bool FixEnabled { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for managing DB-layer environment settings (Layer 3).
|
||||
/// All endpoints require <c>SetupRead</c> or <c>SetupAdmin</c> authorization.
|
||||
/// </summary>
|
||||
public static class EnvironmentSettingsAdminEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEnvironmentSettingsAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/platform/envsettings/db")
|
||||
.WithTags("Environment Settings Admin");
|
||||
|
||||
group.MapGet("/", async (IEnvironmentSettingsStore store, CancellationToken ct) =>
|
||||
{
|
||||
var all = await store.GetAllAsync(ct);
|
||||
return Results.Ok(all);
|
||||
})
|
||||
.WithName("ListDbEnvironmentSettings")
|
||||
.WithSummary("List all DB-layer environment settings")
|
||||
.Produces<IReadOnlyDictionary<string, string>>(StatusCodes.Status200OK)
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
group.MapPut("/{key}", async (string key, SettingValueRequest body, IEnvironmentSettingsStore store, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return Results.BadRequest(new { error = "Key must not be empty." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body.Value))
|
||||
return Results.BadRequest(new { error = "Value must not be empty." });
|
||||
|
||||
await store.SetAsync(key, body.Value, body.UpdatedBy ?? "admin", ct);
|
||||
return Results.Ok(new { key, value = body.Value });
|
||||
})
|
||||
.WithName("UpsertDbEnvironmentSetting")
|
||||
.WithSummary("Create or update a DB-layer environment setting")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(PlatformPolicies.SetupAdmin);
|
||||
|
||||
group.MapDelete("/{key}", async (string key, IEnvironmentSettingsStore store, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return Results.BadRequest(new { error = "Key must not be empty." });
|
||||
|
||||
await store.DeleteAsync(key, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithName("DeleteDbEnvironmentSetting")
|
||||
.WithSummary("Delete a DB-layer environment setting")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.RequireAuthorization(PlatformPolicies.SetupAdmin);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
public sealed record SettingValueRequest(string Value, string? UpdatedBy = null);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Serves <c>GET /platform/envsettings.json</c> — an anonymous endpoint that returns
|
||||
/// the Angular frontend's <see cref="EnvironmentSettingsResponse"/> (matching <c>AppConfig</c>).
|
||||
/// The payload is assembled from three layers: env vars (Layer 1), YAML/JSON config (Layer 2),
|
||||
/// and database overrides (Layer 3) via <see cref="EnvironmentSettingsComposer"/>.
|
||||
/// </summary>
|
||||
public static class EnvironmentSettingsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapEnvironmentSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Primary route (used when accessed via gateway: /platform/envsettings.json)
|
||||
// and alias route (used when accessing the Platform service directly: /envsettings.json)
|
||||
app.MapGet("/envsettings.json", Handler)
|
||||
.WithTags("Environment Settings")
|
||||
.WithName("GetEnvironmentSettingsAlias")
|
||||
.WithSummary("Alias for /platform/envsettings.json (direct service access)")
|
||||
.Produces<EnvironmentSettingsResponse>(StatusCodes.Status200OK)
|
||||
.AllowAnonymous()
|
||||
.ExcludeFromDescription();
|
||||
|
||||
app.MapGet("/platform/envsettings.json", Handler)
|
||||
.WithTags("Environment Settings")
|
||||
.WithName("GetEnvironmentSettings")
|
||||
.WithSummary("Returns frontend environment configuration (AppConfig)")
|
||||
.WithDescription(
|
||||
"Anonymous endpoint that returns the Angular frontend's AppConfig payload. " +
|
||||
"The response merges three configuration layers: environment variables (lowest), " +
|
||||
"YAML/JSON config, and database overrides (highest). Includes OIDC authority settings, " +
|
||||
"API base URLs, and optional telemetry/welcome/doctor configuration.")
|
||||
.Produces<EnvironmentSettingsResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> Handler(
|
||||
IOptions<PlatformServiceOptions> options,
|
||||
EnvironmentSettingsComposer composer,
|
||||
IEnvironmentSettingsStore envSettingsStore,
|
||||
SetupStateDetector setupDetector,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var platform = options.Value;
|
||||
|
||||
var env = await composer.ComposeAsync(ct);
|
||||
|
||||
// Detect setup state from DB settings (cached, no extra round-trip)
|
||||
var dbSettings = await envSettingsStore.GetAllAsync(ct);
|
||||
var setupState = setupDetector.Detect(platform.Storage, dbSettings);
|
||||
|
||||
var authority = new EnvironmentAuthoritySettings
|
||||
{
|
||||
Issuer = platform.Authority.Issuer,
|
||||
ClientId = env.ClientId,
|
||||
AuthorizeEndpoint = env.AuthorizeEndpoint
|
||||
?? $"{platform.Authority.Issuer}/connect/authorize",
|
||||
TokenEndpoint = env.TokenEndpoint
|
||||
?? $"{platform.Authority.Issuer}/connect/token",
|
||||
LogoutEndpoint = env.LogoutEndpoint,
|
||||
RedirectUri = env.RedirectUri,
|
||||
PostLogoutRedirectUri = env.PostLogoutRedirectUri,
|
||||
Scope = env.Scope,
|
||||
Audience = env.Audience
|
||||
?? platform.Authority.Audiences.FirstOrDefault()
|
||||
?? "stella-ops-api",
|
||||
DpopAlgorithms = env.DpopAlgorithms.Count > 0 ? env.DpopAlgorithms : null,
|
||||
RefreshLeewaySeconds = env.RefreshLeewaySeconds,
|
||||
};
|
||||
|
||||
EnvironmentTelemetrySettings? telemetry = null;
|
||||
if (!string.IsNullOrWhiteSpace(env.OtlpEndpoint) || env.TelemetrySampleRate > 0)
|
||||
{
|
||||
telemetry = new EnvironmentTelemetrySettings
|
||||
{
|
||||
OtlpEndpoint = env.OtlpEndpoint,
|
||||
SampleRate = env.TelemetrySampleRate,
|
||||
};
|
||||
}
|
||||
|
||||
EnvironmentWelcomeSettings? welcome = null;
|
||||
if (env.WelcomeTitle is not null || env.WelcomeMessage is not null || env.WelcomeDocsUrl is not null)
|
||||
{
|
||||
welcome = new EnvironmentWelcomeSettings
|
||||
{
|
||||
Title = env.WelcomeTitle,
|
||||
Message = env.WelcomeMessage,
|
||||
DocsUrl = env.WelcomeDocsUrl,
|
||||
};
|
||||
}
|
||||
|
||||
EnvironmentDoctorSettings? doctor = null;
|
||||
if (env.DoctorFixEnabled)
|
||||
{
|
||||
doctor = new EnvironmentDoctorSettings
|
||||
{
|
||||
FixEnabled = env.DoctorFixEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
var response = new EnvironmentSettingsResponse
|
||||
{
|
||||
Authority = authority,
|
||||
ApiBaseUrls = new Dictionary<string, string>(env.ApiBaseUrls),
|
||||
Telemetry = telemetry,
|
||||
Welcome = welcome,
|
||||
Doctor = doctor,
|
||||
Setup = setupState,
|
||||
};
|
||||
|
||||
return Results.Json(response, statusCode: StatusCodes.Status200OK);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ public sealed class PlatformServiceOptions
|
||||
public PlatformSearchOptions Search { get; set; } = new();
|
||||
public PlatformMetadataOptions Metadata { get; set; } = new();
|
||||
public PlatformStorageOptions Storage { get; set; } = new();
|
||||
public PlatformEnvironmentSettingsOptions EnvironmentSettings { get; set; } = new();
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
@@ -22,15 +23,16 @@ public sealed class PlatformServiceOptions
|
||||
Search.Validate();
|
||||
Metadata.Validate();
|
||||
Storage.Validate();
|
||||
EnvironmentSettings.Validate();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlatformAuthorityOptions
|
||||
{
|
||||
public string Issuer { get; set; } = "https://auth.stellaops.local";
|
||||
public string Issuer { get; set; } = "https://authority.stella-ops.local";
|
||||
public string? MetadataAddress { get; set; }
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
public List<string> Audiences { get; set; } = new() { "stellaops-api" };
|
||||
public List<string> Audiences { get; set; } = new() { "stella-ops-api" };
|
||||
public List<string> RequiredScopes { get; set; } = new();
|
||||
public List<string> RequiredTenants { get; set; } = new();
|
||||
public List<string> BypassNetworks { get; set; } = new();
|
||||
@@ -56,6 +58,7 @@ public sealed class PlatformCacheOptions
|
||||
public int SearchSeconds { get; set; } = 20;
|
||||
public int MetadataSeconds { get; set; } = 60;
|
||||
public int AnalyticsSeconds { get; set; } = 300;
|
||||
public int EnvironmentSettingsRefreshSeconds { get; set; } = 60;
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
@@ -69,6 +72,7 @@ public sealed class PlatformCacheOptions
|
||||
RequireNonNegative(SearchSeconds, nameof(SearchSeconds));
|
||||
RequireNonNegative(MetadataSeconds, nameof(MetadataSeconds));
|
||||
RequireNonNegative(AnalyticsSeconds, nameof(AnalyticsSeconds));
|
||||
RequireNonNegative(EnvironmentSettingsRefreshSeconds, nameof(EnvironmentSettingsRefreshSeconds));
|
||||
}
|
||||
|
||||
private static void RequireNonNegative(int value, string name)
|
||||
@@ -157,3 +161,42 @@ public sealed class PlatformAnalyticsMaintenanceOptions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the anonymous <c>GET /platform/envsettings.json</c> endpoint
|
||||
/// that serves the Angular frontend's <c>AppConfig</c> payload.
|
||||
/// Bound from <c>Platform:EnvironmentSettings</c>.
|
||||
/// </summary>
|
||||
public sealed class PlatformEnvironmentSettingsOptions
|
||||
{
|
||||
// --- Authority (frontend OIDC client) ---
|
||||
public string ClientId { get; set; } = "stella-ops-ui";
|
||||
public string? AuthorizeEndpoint { get; set; }
|
||||
public string? TokenEndpoint { get; set; }
|
||||
public string? LogoutEndpoint { get; set; }
|
||||
public string RedirectUri { get; set; } = string.Empty;
|
||||
public string? PostLogoutRedirectUri { get; set; }
|
||||
public string Scope { get; set; } = "openid profile email ui.read";
|
||||
public string? Audience { get; set; }
|
||||
public List<string> DpopAlgorithms { get; set; } = new() { "ES256" };
|
||||
public int RefreshLeewaySeconds { get; set; } = 60;
|
||||
|
||||
// --- API base URLs ---
|
||||
public Dictionary<string, string> ApiBaseUrls { get; set; } = new();
|
||||
|
||||
// --- Telemetry (optional) ---
|
||||
public string? OtlpEndpoint { get; set; }
|
||||
public double TelemetrySampleRate { get; set; }
|
||||
|
||||
// --- Welcome (optional) ---
|
||||
public string? WelcomeTitle { get; set; }
|
||||
public string? WelcomeMessage { get; set; }
|
||||
public string? WelcomeDocsUrl { get; set; }
|
||||
|
||||
// --- Doctor (optional) ---
|
||||
public bool DoctorFixEnabled { get; set; }
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Messaging.DependencyInjection;
|
||||
using StellaOps.Platform.Analytics;
|
||||
using StellaOps.Platform.WebService.Configuration;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Endpoints;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
@@ -40,6 +42,10 @@ builder.Services.AddOptions<PlatformServiceOptions>()
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
// Layer 1: env var post-configure (STELLAOPS_*_URL -> ApiBaseUrls, lowest priority after YAML)
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<PlatformServiceOptions>, StellaOpsEnvVarPostConfigure>();
|
||||
|
||||
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddOpenApi();
|
||||
@@ -162,12 +168,19 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
builder.Services.AddSingleton(
|
||||
Npgsql.NpgsqlDataSource.Create(bootstrapOptions.Storage.PostgresConnectionString));
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, PostgresScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IScoreHistoryStore, InMemoryScoreHistoryStore>();
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, InMemoryEnvironmentSettingsStore>();
|
||||
}
|
||||
|
||||
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
|
||||
builder.Services.AddSingleton<EnvironmentSettingsComposer>();
|
||||
builder.Services.AddSingleton<SetupStateDetector>();
|
||||
builder.Services.AddHostedService<EnvironmentSettingsRefreshService>();
|
||||
|
||||
builder.Services.AddSingleton<IScoreEvaluationService, ScoreEvaluationService>();
|
||||
|
||||
// Policy interop services (import/export between JSON PolicyPack v2 and OPA/Rego)
|
||||
@@ -184,7 +197,9 @@ builder.Services.TryAddStellaRouter(
|
||||
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
|
||||
routerOptions: routerOptions);
|
||||
|
||||
builder.TryAddStellaOpsLocalBinding("platform");
|
||||
var app = builder.Build();
|
||||
app.LogStellaOpsLocalHostname("platform");
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
@@ -196,11 +211,14 @@ if (!string.Equals(bootstrapOptions.Storage.Driver, "memory", StringComparison.O
|
||||
app.Logger.LogWarning("Platform storage driver {Driver} is not implemented; using in-memory stores.", bootstrapOptions.Storage.Driver);
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsTelemetryContext();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
app.MapEnvironmentSettingsEndpoints();
|
||||
app.MapEnvironmentSettingsAdminEndpoints();
|
||||
app.MapPlatformEndpoints();
|
||||
app.MapSetupEndpoints();
|
||||
app.MapAnalyticsEndpoints();
|
||||
|
||||
@@ -4,9 +4,62 @@
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"PLATFORM_ENVIRONMENTSETTINGS__REDIRECTURI": "https://stella-ops.local:10000/auth/callback",
|
||||
"STELLAOPS_WEBSERVICES_CORS": "true",
|
||||
"STELLAOPS_WEBSERVICES_CORS_ORIGIN": "https://stella-ops.local,https://stella-ops.local:10000,https://localhost:10000",
|
||||
|
||||
"STELLAOPS_PLATFORM_URL": "https://platform.stella-ops.local",
|
||||
"STELLAOPS_ROUTER_URL": "https://router.stella-ops.local",
|
||||
"STELLAOPS_AUTHORITY_URL": "https://authority.stella-ops.local",
|
||||
"STELLAOPS_GATEWAY_URL": "https://gateway.stella-ops.local",
|
||||
"STELLAOPS_ATTESTOR_URL": "https://attestor.stella-ops.local",
|
||||
"STELLAOPS_EVIDENCELOCKER_URL": "https://evidencelocker.stella-ops.local",
|
||||
"STELLAOPS_SCANNER_URL": "https://scanner.stella-ops.local",
|
||||
"STELLAOPS_CONCELIER_URL": "https://concelier.stella-ops.local",
|
||||
"STELLAOPS_EXCITITOR_URL": "https://excititor.stella-ops.local",
|
||||
"STELLAOPS_VEXHUB_URL": "https://vexhub.stella-ops.local",
|
||||
"STELLAOPS_VEXLENS_URL": "https://vexlens.stella-ops.local",
|
||||
"STELLAOPS_VULNEXPLORER_URL": "https://vulnexplorer.stella-ops.local",
|
||||
"STELLAOPS_POLICY_ENGINE_URL": "https://policy-engine.stella-ops.local",
|
||||
"STELLAOPS_POLICY_GATEWAY_URL": "https://policy-gateway.stella-ops.local",
|
||||
"STELLAOPS_RISKENGINE_URL": "https://riskengine.stella-ops.local",
|
||||
"STELLAOPS_ORCHESTRATOR_URL": "https://orchestrator.stella-ops.local",
|
||||
"STELLAOPS_TASKRUNNER_URL": "https://taskrunner.stella-ops.local",
|
||||
"STELLAOPS_SCHEDULER_URL": "https://scheduler.stella-ops.local",
|
||||
"STELLAOPS_GRAPH_URL": "https://graph.stella-ops.local",
|
||||
"STELLAOPS_CARTOGRAPHER_URL": "https://cartographer.stella-ops.local",
|
||||
"STELLAOPS_REACHGRAPH_URL": "https://reachgraph.stella-ops.local",
|
||||
"STELLAOPS_TIMELINEINDEXER_URL": "https://timelineindexer.stella-ops.local",
|
||||
"STELLAOPS_TIMELINE_URL": "https://timeline.stella-ops.local",
|
||||
"STELLAOPS_FINDINGS_LEDGER_URL": "https://findings.stella-ops.local",
|
||||
"STELLAOPS_DOCTOR_URL": "https://doctor.stella-ops.local",
|
||||
"STELLAOPS_OPSMEMORY_URL": "https://opsmemory.stella-ops.local",
|
||||
"STELLAOPS_NOTIFIER_URL": "https://notifier.stella-ops.local",
|
||||
"STELLAOPS_NOTIFY_URL": "https://notify.stella-ops.local",
|
||||
"STELLAOPS_SIGNER_URL": "https://signer.stella-ops.local",
|
||||
"STELLAOPS_SMREMOTE_URL": "https://smremote.stella-ops.local",
|
||||
"STELLAOPS_AIRGAP_CONTROLLER_URL": "https://airgap-controller.stella-ops.local",
|
||||
"STELLAOPS_AIRGAP_TIME_URL": "https://airgap-time.stella-ops.local",
|
||||
"STELLAOPS_PACKSREGISTRY_URL": "https://packsregistry.stella-ops.local",
|
||||
"STELLAOPS_REGISTRY_TOKENSERVICE_URL": "https://registry-token.stella-ops.local",
|
||||
"STELLAOPS_BINARYINDEX_URL": "https://binaryindex.stella-ops.local",
|
||||
"STELLAOPS_ISSUERDIRECTORY_URL": "https://issuerdirectory.stella-ops.local",
|
||||
"STELLAOPS_SYMBOLS_URL": "https://symbols.stella-ops.local",
|
||||
"STELLAOPS_SBOMSERVICE_URL": "https://sbomservice.stella-ops.local",
|
||||
"STELLAOPS_EXPORTCENTER_URL": "https://exportcenter.stella-ops.local",
|
||||
"STELLAOPS_REPLAY_URL": "https://replay.stella-ops.local",
|
||||
"STELLAOPS_INTEGRATIONS_URL": "https://integrations.stella-ops.local",
|
||||
"STELLAOPS_SIGNALS_URL": "https://signals.stella-ops.local",
|
||||
"STELLAOPS_ADVISORYAI_URL": "https://advisoryai.stella-ops.local",
|
||||
"STELLAOPS_UNKNOWNS_URL": "https://unknowns.stella-ops.local",
|
||||
|
||||
"STELLAOPS_POLICY_URL": "https://policy-engine.stella-ops.local",
|
||||
"STELLAOPS_LEDGER_URL": "https://findings.stella-ops.local",
|
||||
"STELLAOPS_VEX_URL": "https://vexhub.stella-ops.local",
|
||||
"STELLAOPS_EXCITOR_URL": "https://excititor.stella-ops.local"
|
||||
},
|
||||
"applicationUrl": "https://localhost:52413;http://localhost:52415"
|
||||
"applicationUrl": "https://localhost:10010;http://localhost:10011"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Composes a final <see cref="PlatformEnvironmentSettingsOptions"/> from three layers:
|
||||
/// <list type="number">
|
||||
/// <item>Environment variables (via <see cref="Configuration.StellaOpsEnvVarPostConfigure"/>)</item>
|
||||
/// <item>YAML/JSON configuration (standard IOptions binding)</item>
|
||||
/// <item>Database (via <see cref="IEnvironmentSettingsStore"/>)</item>
|
||||
/// </list>
|
||||
/// Layer 3 (DB) has highest priority and is overlaid last.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentSettingsComposer
|
||||
{
|
||||
private readonly IOptionsMonitor<PlatformServiceOptions> _optionsMonitor;
|
||||
private readonly IEnvironmentSettingsStore _store;
|
||||
|
||||
public EnvironmentSettingsComposer(
|
||||
IOptionsMonitor<PlatformServiceOptions> optionsMonitor,
|
||||
IEnvironmentSettingsStore store)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the merged environment settings with all three layers applied.
|
||||
/// </summary>
|
||||
public async Task<PlatformEnvironmentSettingsOptions> ComposeAsync(CancellationToken ct = default)
|
||||
{
|
||||
var source = _optionsMonitor.CurrentValue.EnvironmentSettings;
|
||||
|
||||
// Clone the options so we don't mutate the originals
|
||||
var merged = new PlatformEnvironmentSettingsOptions
|
||||
{
|
||||
ClientId = source.ClientId,
|
||||
AuthorizeEndpoint = source.AuthorizeEndpoint,
|
||||
TokenEndpoint = source.TokenEndpoint,
|
||||
LogoutEndpoint = source.LogoutEndpoint,
|
||||
RedirectUri = source.RedirectUri,
|
||||
PostLogoutRedirectUri = source.PostLogoutRedirectUri,
|
||||
Scope = source.Scope,
|
||||
Audience = source.Audience,
|
||||
DpopAlgorithms = new List<string>(source.DpopAlgorithms),
|
||||
RefreshLeewaySeconds = source.RefreshLeewaySeconds,
|
||||
ApiBaseUrls = new Dictionary<string, string>(source.ApiBaseUrls, StringComparer.OrdinalIgnoreCase),
|
||||
OtlpEndpoint = source.OtlpEndpoint,
|
||||
TelemetrySampleRate = source.TelemetrySampleRate,
|
||||
WelcomeTitle = source.WelcomeTitle,
|
||||
WelcomeMessage = source.WelcomeMessage,
|
||||
WelcomeDocsUrl = source.WelcomeDocsUrl,
|
||||
DoctorFixEnabled = source.DoctorFixEnabled,
|
||||
};
|
||||
|
||||
// Overlay DB values (Layer 3 — highest priority)
|
||||
var dbSettings = await _store.GetAllAsync(ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var (key, value) in dbSettings)
|
||||
{
|
||||
if (key.StartsWith("ApiBaseUrls:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var serviceName = key["ApiBaseUrls:".Length..];
|
||||
merged.ApiBaseUrls[serviceName] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Map scalar settings by key name
|
||||
ApplyScalarSetting(merged, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static void ApplyScalarSetting(PlatformEnvironmentSettingsOptions options, string key, string value)
|
||||
{
|
||||
// Case-insensitive key matching for known scalar settings
|
||||
if (string.Equals(key, "ClientId", StringComparison.OrdinalIgnoreCase))
|
||||
options.ClientId = value;
|
||||
else if (string.Equals(key, "AuthorizeEndpoint", StringComparison.OrdinalIgnoreCase))
|
||||
options.AuthorizeEndpoint = value;
|
||||
else if (string.Equals(key, "TokenEndpoint", StringComparison.OrdinalIgnoreCase))
|
||||
options.TokenEndpoint = value;
|
||||
else if (string.Equals(key, "LogoutEndpoint", StringComparison.OrdinalIgnoreCase))
|
||||
options.LogoutEndpoint = value;
|
||||
else if (string.Equals(key, "RedirectUri", StringComparison.OrdinalIgnoreCase))
|
||||
options.RedirectUri = value;
|
||||
else if (string.Equals(key, "PostLogoutRedirectUri", StringComparison.OrdinalIgnoreCase))
|
||||
options.PostLogoutRedirectUri = value;
|
||||
else if (string.Equals(key, "Scope", StringComparison.OrdinalIgnoreCase))
|
||||
options.Scope = value;
|
||||
else if (string.Equals(key, "Audience", StringComparison.OrdinalIgnoreCase))
|
||||
options.Audience = value;
|
||||
else if (string.Equals(key, "OtlpEndpoint", StringComparison.OrdinalIgnoreCase))
|
||||
options.OtlpEndpoint = value;
|
||||
else if (string.Equals(key, "TelemetrySampleRate", StringComparison.OrdinalIgnoreCase)
|
||||
&& double.TryParse(value, out var rate))
|
||||
options.TelemetrySampleRate = rate;
|
||||
else if (string.Equals(key, "WelcomeTitle", StringComparison.OrdinalIgnoreCase))
|
||||
options.WelcomeTitle = value;
|
||||
else if (string.Equals(key, "WelcomeMessage", StringComparison.OrdinalIgnoreCase))
|
||||
options.WelcomeMessage = value;
|
||||
else if (string.Equals(key, "WelcomeDocsUrl", StringComparison.OrdinalIgnoreCase))
|
||||
options.WelcomeDocsUrl = value;
|
||||
else if (string.Equals(key, "DoctorFixEnabled", StringComparison.OrdinalIgnoreCase)
|
||||
&& bool.TryParse(value, out var fix))
|
||||
options.DoctorFixEnabled = fix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that periodically invalidates the <see cref="IEnvironmentSettingsStore"/>
|
||||
/// cache so DB-layer changes are picked up without restart.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentSettingsRefreshService : BackgroundService
|
||||
{
|
||||
private readonly IEnvironmentSettingsStore _store;
|
||||
private readonly IOptionsMonitor<PlatformServiceOptions> _optionsMonitor;
|
||||
private readonly ILogger<EnvironmentSettingsRefreshService> _logger;
|
||||
|
||||
public EnvironmentSettingsRefreshService(
|
||||
IEnvironmentSettingsStore store,
|
||||
IOptionsMonitor<PlatformServiceOptions> optionsMonitor,
|
||||
ILogger<EnvironmentSettingsRefreshService> logger)
|
||||
{
|
||||
_store = store;
|
||||
_optionsMonitor = optionsMonitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("EnvironmentSettingsRefreshService started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var seconds = _optionsMonitor.CurrentValue.Cache.EnvironmentSettingsRefreshSeconds;
|
||||
if (seconds <= 0) seconds = 60;
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(seconds), stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_store.InvalidateCache();
|
||||
_logger.LogDebug("Environment settings cache invalidated");
|
||||
}
|
||||
|
||||
_logger.LogInformation("EnvironmentSettingsRefreshService stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence interface for DB-layer environment settings (Layer 3).
|
||||
/// Keys follow the pattern <c>ApiBaseUrls:scanner</c>, <c>OtlpEndpoint</c>, etc.
|
||||
/// </summary>
|
||||
public interface IEnvironmentSettingsStore
|
||||
{
|
||||
Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<string?> GetAsync(string key, CancellationToken ct = default);
|
||||
Task SetAsync(string key, string value, string updatedBy = "system", CancellationToken ct = default);
|
||||
Task DeleteAsync(string key, CancellationToken ct = default);
|
||||
void InvalidateCache();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IEnvironmentSettingsStore"/> for development
|
||||
/// and environments without PostgreSQL.
|
||||
/// </summary>
|
||||
public sealed class InMemoryEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
var snapshot = new Dictionary<string, string>(_store, StringComparer.OrdinalIgnoreCase);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, string>>(snapshot);
|
||||
}
|
||||
|
||||
public Task<string?> GetAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
_store.TryGetValue(key, out var value);
|
||||
return Task.FromResult(value);
|
||||
}
|
||||
|
||||
public Task SetAsync(string key, string value, string updatedBy = "system", CancellationToken ct = default)
|
||||
{
|
||||
_store[key] = value;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
_store.TryRemove(key, out _);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void InvalidateCache()
|
||||
{
|
||||
// No-op for in-memory store.
|
||||
}
|
||||
}
|
||||
@@ -23,15 +23,18 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
public sealed class PlatformSetupService
|
||||
{
|
||||
private readonly PlatformSetupStore _store;
|
||||
private readonly IEnvironmentSettingsStore _envSettingsStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<PlatformSetupService> _logger;
|
||||
|
||||
public PlatformSetupService(
|
||||
PlatformSetupStore store,
|
||||
IEnvironmentSettingsStore envSettingsStore,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PlatformSetupService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_envSettingsStore = envSettingsStore ?? throw new ArgumentNullException(nameof(envSettingsStore));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
@@ -118,7 +121,7 @@ public sealed class PlatformSetupService
|
||||
/// <summary>
|
||||
/// Executes a setup step with validation.
|
||||
/// </summary>
|
||||
public Task<ExecuteSetupStepResponse> ExecuteStepAsync(
|
||||
public async Task<ExecuteSetupStepResponse> ExecuteStepAsync(
|
||||
PlatformRequestContext context,
|
||||
ExecuteSetupStepRequest request,
|
||||
CancellationToken ct)
|
||||
@@ -145,11 +148,11 @@ public sealed class PlatformSetupService
|
||||
ErrorMessage = $"Blocked by incomplete dependencies: {string.Join(", ", blockedByDeps)}"
|
||||
};
|
||||
|
||||
return Task.FromResult(new ExecuteSetupStepResponse(
|
||||
return new ExecuteSetupStepResponse(
|
||||
StepState: stepState,
|
||||
Success: false,
|
||||
ErrorMessage: stepState.ErrorMessage,
|
||||
SuggestedFixes: ImmutableArray<SetupSuggestedFix>.Empty));
|
||||
SuggestedFixes: ImmutableArray<SetupSuggestedFix>.Empty);
|
||||
}
|
||||
|
||||
var nowUtc = _timeProvider.GetUtcNow();
|
||||
@@ -189,6 +192,23 @@ public sealed class PlatformSetupService
|
||||
|
||||
_store.Upsert(context.TenantId, updatedSession);
|
||||
|
||||
// Record resume point so the frontend can resume at the next step
|
||||
if (allPassed)
|
||||
{
|
||||
var nextStep = updatedSteps
|
||||
.Where(s => s.Status != SetupStepStatus.Passed && s.Status != SetupStepStatus.Skipped)
|
||||
.OrderBy(s => (int)s.StepId)
|
||||
.FirstOrDefault();
|
||||
|
||||
var resumeValue = nextStep is not null
|
||||
? nextStep.StepId.ToString().ToLowerInvariant()
|
||||
: "true"; // all steps done
|
||||
|
||||
await _envSettingsStore.SetAsync(
|
||||
SetupStateDetector.SetupCompleteKey, resumeValue, "setup-wizard", ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Executed step {StepId} for session {SessionId}: {Status}.",
|
||||
request.StepId, session.SessionId, newStatus);
|
||||
@@ -197,11 +217,11 @@ public sealed class PlatformSetupService
|
||||
? ImmutableArray<SetupSuggestedFix>.Empty
|
||||
: GenerateSuggestedFixes(stepDef, checkResults);
|
||||
|
||||
return Task.FromResult(new ExecuteSetupStepResponse(
|
||||
return new ExecuteSetupStepResponse(
|
||||
StepState: updatedStepState,
|
||||
Success: allPassed,
|
||||
ErrorMessage: errorMessage,
|
||||
SuggestedFixes: suggestedFixes));
|
||||
SuggestedFixes: suggestedFixes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -266,7 +286,7 @@ public sealed class PlatformSetupService
|
||||
/// <summary>
|
||||
/// Finalizes the setup session.
|
||||
/// </summary>
|
||||
public Task<FinalizeSetupSessionResponse> FinalizeSessionAsync(
|
||||
public async Task<FinalizeSetupSessionResponse> FinalizeSessionAsync(
|
||||
PlatformRequestContext context,
|
||||
FinalizeSetupSessionRequest request,
|
||||
CancellationToken ct)
|
||||
@@ -319,16 +339,24 @@ public sealed class PlatformSetupService
|
||||
|
||||
_store.Upsert(context.TenantId, updatedSession);
|
||||
|
||||
// Mark setup as complete in environment settings so the frontend knows
|
||||
if (finalStatus == SetupSessionStatus.Completed || finalStatus == SetupSessionStatus.CompletedPartial)
|
||||
{
|
||||
await _envSettingsStore.SetAsync(
|
||||
SetupStateDetector.SetupCompleteKey, "true", "setup-wizard", ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Finalized setup session {SessionId} with status {Status}.",
|
||||
session.SessionId, finalStatus);
|
||||
|
||||
return Task.FromResult(new FinalizeSetupSessionResponse(
|
||||
return new FinalizeSetupSessionResponse(
|
||||
FinalStatus: finalStatus,
|
||||
CompletedSteps: completedSteps,
|
||||
SkippedSteps: skippedSteps,
|
||||
FailedSteps: failedSteps,
|
||||
ReportPath: null));
|
||||
ReportPath: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="IEnvironmentSettingsStore"/>.
|
||||
/// Reads from <c>platform.environment_settings</c> with an in-memory cache
|
||||
/// that is invalidated periodically by <see cref="EnvironmentSettingsRefreshService"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresEnvironmentSettingsStore> _logger;
|
||||
private volatile IReadOnlyDictionary<string, string>? _cache;
|
||||
private readonly object _cacheLock = new();
|
||||
|
||||
private const string SelectAllSql = """
|
||||
SELECT key, value FROM platform.environment_settings ORDER BY key
|
||||
""";
|
||||
|
||||
private const string SelectOneSql = """
|
||||
SELECT value FROM platform.environment_settings WHERE key = @key
|
||||
""";
|
||||
|
||||
private const string UpsertSql = """
|
||||
INSERT INTO platform.environment_settings (key, value, updated_at, updated_by)
|
||||
VALUES (@key, @value, now(), @updated_by)
|
||||
ON CONFLICT (key) DO UPDATE SET value = @value, updated_at = now(), updated_by = @updated_by
|
||||
""";
|
||||
|
||||
private const string DeleteSql = """
|
||||
DELETE FROM platform.environment_settings WHERE key = @key
|
||||
""";
|
||||
|
||||
public PostgresEnvironmentSettingsStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresEnvironmentSettingsStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresEnvironmentSettingsStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetAllAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cached = _cache;
|
||||
if (cached is not null)
|
||||
return cached;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectAllSql, conn);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
dict[reader.GetString(0)] = reader.GetString(1);
|
||||
}
|
||||
|
||||
var result = dict;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache ??= result;
|
||||
}
|
||||
|
||||
return _cache;
|
||||
}
|
||||
|
||||
public async Task<string?> GetAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
var all = await GetAllAsync(ct).ConfigureAwait(false);
|
||||
return all.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
public async Task SetAsync(string key, string value, string updatedBy = "system", CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(UpsertSql, conn);
|
||||
cmd.Parameters.AddWithValue("key", key);
|
||||
cmd.Parameters.AddWithValue("value", value);
|
||||
cmd.Parameters.AddWithValue("updated_by", updatedBy);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation("Environment setting {Key} updated by {UpdatedBy}", key, updatedBy);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string key, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(DeleteSql, conn);
|
||||
cmd.Parameters.AddWithValue("key", key);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation("Environment setting {Key} deleted ({Rows} rows affected)", key, rows);
|
||||
}
|
||||
|
||||
public void InvalidateCache()
|
||||
{
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Detects whether the platform requires initial setup, has completed setup,
|
||||
/// or is mid-way through the setup wizard.
|
||||
/// </summary>
|
||||
public sealed class SetupStateDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// DB settings key written by the setup wizard to record completion or resume state.
|
||||
/// </summary>
|
||||
public const string SetupCompleteKey = "SetupComplete";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the setup state for inclusion in the <c>envsettings.json</c> response.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>null</c> — setup required (no DB configured, or fresh empty DB)</item>
|
||||
/// <item><c>"complete"</c> — setup done, normal operation</item>
|
||||
/// <item>Any other value — a <c>SetupStepId</c> to resume the wizard at</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public string? Detect(
|
||||
PlatformStorageOptions storage,
|
||||
IReadOnlyDictionary<string, string> dbSettings)
|
||||
{
|
||||
// 1. No DB configured → needs setup
|
||||
if (string.IsNullOrWhiteSpace(storage.PostgresConnectionString))
|
||||
return null;
|
||||
|
||||
// 2. Explicit SetupComplete key in DB
|
||||
if (dbSettings.TryGetValue(SetupCompleteKey, out var value))
|
||||
{
|
||||
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
? "complete"
|
||||
: value; // step ID to resume at
|
||||
}
|
||||
|
||||
// 3. No SetupComplete key but other settings exist → existing deployment (upgrade scenario)
|
||||
if (dbSettings.Count > 0)
|
||||
return "complete";
|
||||
|
||||
// 4. Empty DB settings + DB configured → fresh DB, needs setup
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration: 044_PlatformEnvironmentSettings
|
||||
-- Purpose: Create platform.environment_settings table for DB-layer service URL configuration
|
||||
-- Sprint: SPRINT_20260202_001_Platform_port_registry_env_config
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.environment_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT NOT NULL DEFAULT 'system'
|
||||
);
|
||||
|
||||
COMMENT ON TABLE platform.environment_settings IS
|
||||
'Key-value store for environment settings (Layer 3). Keys follow the pattern ApiBaseUrls:{service}, OtlpEndpoint, ClientId, etc.';
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) StellaOps. All rights reserved.
|
||||
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
||||
|
||||
using StellaOps.Platform.WebService.Options;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SetupStateDetectorTests
|
||||
{
|
||||
private readonly SetupStateDetector _detector = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_NoPostgresConnectionString_ReturnsNull()
|
||||
{
|
||||
var storage = new PlatformStorageOptions { PostgresConnectionString = null };
|
||||
var dbSettings = new Dictionary<string, string>();
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_EmptyPostgresConnectionString_ReturnsNull()
|
||||
{
|
||||
var storage = new PlatformStorageOptions { PostgresConnectionString = " " };
|
||||
var dbSettings = new Dictionary<string, string>();
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_FreshDbNoKeys_ReturnsNull()
|
||||
{
|
||||
var storage = new PlatformStorageOptions
|
||||
{
|
||||
PostgresConnectionString = "Host=localhost;Database=stella"
|
||||
};
|
||||
var dbSettings = new Dictionary<string, string>();
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_SetupCompleteTrue_ReturnsComplete()
|
||||
{
|
||||
var storage = new PlatformStorageOptions
|
||||
{
|
||||
PostgresConnectionString = "Host=localhost;Database=stella"
|
||||
};
|
||||
var dbSettings = new Dictionary<string, string>
|
||||
{
|
||||
[SetupStateDetector.SetupCompleteKey] = "true"
|
||||
};
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Equal("complete", result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_SetupCompleteTrueCaseInsensitive_ReturnsComplete()
|
||||
{
|
||||
var storage = new PlatformStorageOptions
|
||||
{
|
||||
PostgresConnectionString = "Host=localhost;Database=stella"
|
||||
};
|
||||
var dbSettings = new Dictionary<string, string>
|
||||
{
|
||||
[SetupStateDetector.SetupCompleteKey] = "True"
|
||||
};
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Equal("complete", result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_SetupCompleteStepId_ReturnsStepId()
|
||||
{
|
||||
var storage = new PlatformStorageOptions
|
||||
{
|
||||
PostgresConnectionString = "Host=localhost;Database=stella"
|
||||
};
|
||||
var dbSettings = new Dictionary<string, string>
|
||||
{
|
||||
[SetupStateDetector.SetupCompleteKey] = "migrations"
|
||||
};
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Equal("migrations", result);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Detect_ExistingDeploymentNoSetupCompleteKey_ReturnsComplete()
|
||||
{
|
||||
var storage = new PlatformStorageOptions
|
||||
{
|
||||
PostgresConnectionString = "Host=localhost;Database=stella"
|
||||
};
|
||||
var dbSettings = new Dictionary<string, string>
|
||||
{
|
||||
["ApiBaseUrls:scanner"] = "https://scanner.local",
|
||||
["ClientId"] = "stella-ops-ui"
|
||||
};
|
||||
|
||||
var result = _detector.Detect(storage, dbSettings);
|
||||
|
||||
Assert.Equal("complete", result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user