stabilizaiton work - projects rework for maintenanceability and ui livening

This commit is contained in:
master
2026-02-03 23:40:04 +02:00
parent 074ce117ba
commit 557feefdc3
3305 changed files with 186813 additions and 107843 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.';

View File

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