stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search

This commit is contained in:
master
2026-02-22 19:27:54 +02:00
parent a29f438f53
commit bd8fee6ed8
373 changed files with 832097 additions and 3369 deletions

View File

@@ -0,0 +1,374 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Platform.Database;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Services;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// Administrative migration endpoints used by CLI and UI orchestration paths.
/// </summary>
public static class MigrationAdminEndpoints
{
public static IEndpointRouteBuilder MapMigrationAdminEndpoints(this IEndpointRouteBuilder app)
{
var migrations = app.MapGroup("/api/v1/admin/migrations")
.WithTags("Admin - Migrations")
.RequireAuthorization(PlatformPolicies.SetupAdmin);
migrations.MapGet("/modules", () =>
{
var modules = MigrationModuleRegistry.ModuleNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
return Results.Ok(new MigrationModulesResponse { Modules = modules });
})
.WithName("GetMigrationModules")
.Produces<MigrationModulesResponse>(StatusCodes.Status200OK);
migrations.MapGet("/status", async Task<IResult> (
string? module,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Status");
if (!TryResolveModules(module, out var modules, out var failure))
{
return failure!;
}
try
{
var results = new List<MigrationStatusModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var status = await migrationService.GetStatusAsync(moduleInfo, ct).ConfigureAwait(false);
results.Add(new MigrationStatusModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
AppliedCount = status.AppliedCount,
PendingStartupCount = status.PendingStartupCount,
PendingReleaseCount = status.PendingReleaseCount,
LastAppliedMigration = status.LastAppliedMigration,
LastAppliedAt = status.LastAppliedAt,
PendingMigrations = status.PendingMigrations
.OrderBy(static pending => pending.Name, StringComparer.Ordinal)
.Select(static pending => new PendingMigrationResult
{
Name = pending.Name,
Category = pending.Category.ToString().ToLowerInvariant()
})
.ToArray(),
ChecksumErrors = status.ChecksumErrors
.OrderBy(static error => error, StringComparer.Ordinal)
.ToArray(),
IsUpToDate = status.IsUpToDate,
HasBlockingIssues = status.HasBlockingIssues
});
}
return Results.Ok(new MigrationStatusResponse { Modules = results });
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration status failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration status failed",
detail: "Unexpected server error while reading migration status.");
}
})
.WithName("GetMigrationStatus")
.Produces<MigrationStatusResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
migrations.MapGet("/verify", async Task<IResult> (
string? module,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Verify");
if (!TryResolveModules(module, out var modules, out var failure))
{
return failure!;
}
try
{
var results = new List<MigrationVerifyModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var errors = await migrationService.VerifyAsync(moduleInfo, ct).ConfigureAwait(false);
var orderedErrors = errors.OrderBy(static error => error, StringComparer.Ordinal).ToArray();
results.Add(new MigrationVerifyModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
Success = orderedErrors.Length == 0,
Errors = orderedErrors
});
}
return Results.Ok(new MigrationVerifyResponse { Modules = results });
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration verify failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration verify failed",
detail: "Unexpected server error while verifying migrations.");
}
})
.WithName("VerifyMigrations")
.Produces<MigrationVerifyResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
migrations.MapPost("/run", async Task<IResult> (
MigrationRunRequest? request,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Run");
request ??= new MigrationRunRequest();
if (!TryResolveModules(request.Module, out var modules, out var moduleFailure))
{
return moduleFailure!;
}
if (!TryParseCategory(request.Category, out var category))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid category",
Detail = "Category must be one of: startup, release, seed, data.",
Status = StatusCodes.Status400BadRequest
});
}
if (category == MigrationCategory.Release && !request.DryRun && !request.Force)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Release migration approval required",
Detail = "Release migrations require dry-run preview or explicit force approval.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.TimeoutSeconds is <= 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid timeout",
Detail = "TimeoutSeconds must be greater than zero when provided.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var results = new List<MigrationRunModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var result = await migrationService
.RunAsync(moduleInfo, category, request.DryRun, request.TimeoutSeconds, ct)
.ConfigureAwait(false);
results.Add(new MigrationRunModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
Success = result.Success,
AppliedCount = result.AppliedCount,
SkippedCount = result.SkippedCount,
FilteredCount = result.FilteredCount,
DurationMs = result.DurationMs,
Error = result.ErrorMessage,
ChecksumErrors = result.ChecksumErrors
.OrderBy(static error => error, StringComparer.Ordinal)
.ToArray()
});
}
return Results.Ok(new MigrationRunResponse
{
DryRun = request.DryRun,
Category = category?.ToString().ToLowerInvariant(),
Success = results.All(static result => result.Success),
Modules = results
});
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration run failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration run failed",
detail: "Unexpected server error while running migrations.");
}
})
.WithName("RunMigrations")
.Produces<MigrationRunResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
return app;
}
private static bool TryResolveModules(
string? moduleFilter,
out List<MigrationModuleInfo> modules,
out IResult? failure)
{
modules = MigrationModuleRegistry
.GetModules(moduleFilter)
.OrderBy(static module => module.Name, StringComparer.Ordinal)
.ToList();
if (modules.Count > 0)
{
failure = null;
return true;
}
var available = string.Join(", ", MigrationModuleRegistry.ModuleNames.OrderBy(static name => name, StringComparer.Ordinal));
failure = Results.BadRequest(new ProblemDetails
{
Title = "Invalid module filter",
Detail = $"No modules matched '{moduleFilter}'. Available modules: {available}.",
Status = StatusCodes.Status400BadRequest
});
return false;
}
private static bool TryParseCategory(string? value, out MigrationCategory? category)
{
if (string.IsNullOrWhiteSpace(value))
{
category = null;
return true;
}
category = value.Trim().ToLowerInvariant() switch
{
"startup" => MigrationCategory.Startup,
"release" => MigrationCategory.Release,
"seed" => MigrationCategory.Seed,
"data" => MigrationCategory.Data,
_ => null
};
return category is not null;
}
public sealed class MigrationModulesResponse
{
public IReadOnlyList<string> Modules { get; init; } = [];
}
public sealed class MigrationStatusResponse
{
public IReadOnlyList<MigrationStatusModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationStatusModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public int AppliedCount { get; init; }
public int PendingStartupCount { get; init; }
public int PendingReleaseCount { get; init; }
public string? LastAppliedMigration { get; init; }
public DateTimeOffset? LastAppliedAt { get; init; }
public IReadOnlyList<PendingMigrationResult> PendingMigrations { get; init; } = [];
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
public bool IsUpToDate { get; init; }
public bool HasBlockingIssues { get; init; }
}
public sealed class PendingMigrationResult
{
public string Name { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
}
public sealed class MigrationVerifyResponse
{
public IReadOnlyList<MigrationVerifyModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationVerifyModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public bool Success { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
}
public sealed class MigrationRunRequest
{
public string Module { get; init; } = "all";
public string? Category { get; init; }
public bool DryRun { get; init; }
public bool Force { get; init; }
public int? TimeoutSeconds { get; init; }
}
public sealed class MigrationRunResponse
{
public bool DryRun { get; init; }
public string? Category { get; init; }
public bool Success { get; init; }
public IReadOnlyList<MigrationRunModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationRunModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public bool Success { get; init; }
public int AppliedCount { get; init; }
public int SkippedCount { get; init; }
public int FilteredCount { get; init; }
public long DurationMs { get; init; }
public string? Error { get; init; }
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
}
}

View File

@@ -57,7 +57,7 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddStellaOpsTelemetry(
builder.Configuration,
serviceName: "StellaOps.Platform",
serviceVersion: typeof(Program).Assembly.GetName().Version?.ToString(),
serviceVersion: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
configureMetrics: meterBuilder =>
{
meterBuilder.AddMeter("StellaOps.Platform.Aggregation");
@@ -187,6 +187,7 @@ builder.Services.AddAnalyticsIngestion(builder.Configuration, bootstrapOptions.S
builder.Services.AddSingleton<PlatformSetupStore>();
builder.Services.AddSingleton<PlatformSetupService>();
builder.Services.AddSingleton<PlatformMigrationAdminService>();
// Score evaluation services (TSF-005/TSF-011)
builder.Services.AddUnifiedScoreServices();
@@ -230,11 +231,11 @@ builder.Services.AddSingleton<StellaOps.Scanner.Reachability.FunctionMap.Verific
StellaOps.Scanner.Reachability.FunctionMap.Verification.ClaimVerifier>();
builder.Services.AddSingleton<IFunctionMapService, FunctionMapService>();
var routerOptions = builder.Configuration.GetSection("Platform:Router").Get<StellaRouterOptionsBase>();
builder.Services.TryAddStellaRouter(
var routerEnabled = builder.Services.AddRouterMicroservice(
builder.Configuration,
serviceName: "platform",
version: typeof(Program).Assembly.GetName().Version?.ToString() ?? "1.0.0",
routerOptions: routerOptions);
version: System.Reflection.CustomAttributeExtensions.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(System.Reflection.Assembly.GetExecutingAssembly())?.InformationalVersion ?? "1.0.0",
routerOptionsSection: "Router");
builder.TryAddStellaOpsLocalBinding("platform");
var app = builder.Build();
@@ -254,7 +255,7 @@ app.UseStellaOpsCors();
app.UseStellaOpsTelemetryContext();
app.UseAuthentication();
app.UseAuthorization();
app.TryUseStellaRouter(routerOptions);
app.TryUseStellaRouter(routerEnabled);
var legacyAliasTelemetry = app.Services.GetRequiredService<LegacyAliasTelemetry>();
app.Use(async (context, next) =>
@@ -285,6 +286,7 @@ app.MapPackAdapterEndpoints();
app.MapAdministrationTrustSigningMutationEndpoints();
app.MapFederationTelemetryEndpoints();
app.MapSeedEndpoints();
app.MapMigrationAdminEndpoints();
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }))
.WithTags("Health")
@@ -294,8 +296,11 @@ app.MapGet("/readyz", () => Results.Ok(new { status = "ready" }))
.WithTags("Health")
.AllowAnonymous();
app.TryRefreshStellaRouterEndpoints(routerOptions);
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();
public partial class Program;

View File

@@ -0,0 +1,116 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Platform.Database;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Executes migration operations for Platform administrative APIs.
/// </summary>
internal sealed class PlatformMigrationAdminService
{
private readonly IConfiguration _configuration;
private readonly ILoggerFactory _loggerFactory;
public PlatformMigrationAdminService(IConfiguration configuration, ILoggerFactory loggerFactory)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
}
public Task<MigrationResult> RunAsync(
MigrationModuleInfo module,
MigrationCategory? category,
bool dryRun,
int? timeoutSeconds,
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var runner = CreateRunner(module, connectionString);
var options = new MigrationRunOptions
{
CategoryFilter = category,
DryRun = dryRun,
TimeoutSeconds = timeoutSeconds.GetValueOrDefault(300),
ValidateChecksums = true,
FailOnChecksumMismatch = true
};
return runner.RunFromAssemblyAsync(module.MigrationsAssembly, module.ResourcePrefix, options, cancellationToken);
}
public async Task<MigrationStatus> GetStatusAsync(
MigrationModuleInfo module,
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var logger = _loggerFactory.CreateLogger($"platform.migrationstatus.{module.Name}");
var statusService = new MigrationStatusService(
connectionString,
module.SchemaName,
module.Name,
module.MigrationsAssembly,
logger);
return await statusService.GetStatusAsync(cancellationToken).ConfigureAwait(false);
}
public Task<IReadOnlyList<string>> VerifyAsync(
MigrationModuleInfo module,
CancellationToken cancellationToken)
{
var connectionString = ResolveConnectionString(module);
var runner = CreateRunner(module, connectionString);
return runner.ValidateChecksumsAsync(module.MigrationsAssembly, module.ResourcePrefix, cancellationToken);
}
private MigrationRunner CreateRunner(MigrationModuleInfo module, string connectionString) =>
new(connectionString, module.SchemaName, module.Name, _loggerFactory.CreateLogger($"platform.migration.{module.Name}"));
private string ResolveConnectionString(MigrationModuleInfo module)
{
var envCandidates = new[]
{
$"STELLAOPS_POSTGRES_{module.Name.ToUpperInvariant()}_CONNECTION",
$"STELLAOPS_POSTGRES_{module.SchemaName.ToUpperInvariant()}_CONNECTION",
"STELLAOPS_POSTGRES_CONNECTION",
"STELLAOPS_DB_CONNECTION"
};
foreach (var key in envCandidates)
{
var value = Environment.GetEnvironmentVariable(key);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
var configCandidates = new[]
{
$"StellaOps:Database:{module.Name}:ConnectionString",
$"Database:{module.Name}:ConnectionString",
"Platform:Storage:PostgresConnectionString",
"StellaOps:Postgres:ConnectionString",
"Postgres:ConnectionString",
"Database:ConnectionString",
"ConnectionStrings:Postgres",
"ConnectionStrings:Default"
};
foreach (var key in configCandidates)
{
var value = _configuration[key];
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
throw new InvalidOperationException(
$"No PostgreSQL connection string found for module '{module.Name}'. " +
"Configure Platform:Storage:PostgresConnectionString or STELLAOPS_POSTGRES_CONNECTION.");
}
}

View File

@@ -779,12 +779,15 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
throw new InvalidOperationException("tenant_required");
}
if (!Guid.TryParse(tenantId, out var tenantGuid))
if (Guid.TryParse(tenantId, out var tenantGuid))
{
throw new InvalidOperationException("tenant_id_invalid");
return tenantGuid;
}
return tenantGuid;
// Derive deterministic GUID from string tenant identifier (e.g. "default", "demo-prod")
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(tenantId));
return new Guid(hash.AsSpan(0, 16));
}
private async Task<NpgsqlConnection> OpenTenantConnectionAsync(Guid tenantId, CancellationToken cancellationToken)

View File

@@ -26,6 +26,7 @@
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy.Interop\StellaOps.Policy.Interop.csproj" />
<ProjectReference Include="..\__Libraries\StellaOps.Platform.Database\StellaOps.Platform.Database.csproj" />
<!-- Persistence modules for demo data seeding (SeedEndpoints) -->
<ProjectReference Include="..\..\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="..\..\Scheduler\__Libraries\StellaOps.Scheduler.Persistence\StellaOps.Scheduler.Persistence.csproj" />
@@ -35,4 +36,8 @@
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
</ItemGroup>
<PropertyGroup Label="StellaOpsReleaseVersion">
<Version>1.0.0-alpha1</Version>
<InformationalVersion>1.0.0-alpha1</InformationalVersion>
</PropertyGroup>
</Project>

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-MGC-12 | DONE | Added `/api/v1/admin/migrations/{modules,status,verify,run}` endpoints with `platform.setup.admin` authorization and server-side migration execution wired to the platform-owned registry in `StellaOps.Platform.Database`. |
| SPRINT_20260221_043-PLATFORM-SEED-001 | DONE | Sprint `docs/implplan/SPRINT_20260221_043_DOCS_setup_seed_error_handling_stabilization.md`: fix seed endpoint authorization policy wiring and return structured non-500 error responses for expected failures. |
| PACK-ADM-01 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented Pack-21 Administration A1-A7 adapter endpoints under `/api/v1/administration/*` with deterministic migration alias metadata. |
| PACK-ADM-02 | DONE | Sprint `docs-archived/implplan/SPRINT_20260219_016_Orchestrator_pack_backend_contract_enrichment_exists_adapt.md`: implemented trust owner mutation/read endpoints under `/api/v1/administration/trust-signing/*` with `trust:write`/`trust:admin` policy mapping and DB backing via migration `046_TrustSigningAdministration.sql`. |

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Platform.Database;
/// <summary>
/// Plug-in contract for one consolidated migration module per web service.
/// </summary>
public interface IMigrationModulePlugin
{
MigrationModuleInfo Module { get; }
}

View File

@@ -0,0 +1,164 @@
using System.Reflection;
using System.Runtime.Loader;
namespace StellaOps.Platform.Database;
internal static class MigrationModulePluginDiscovery
{
private static readonly string[] PluginDirectoryEnvironmentVariables =
[
"STELLAOPS_MIGRATION_PLUGIN_DIR",
"STELLAOPS_MIGRATIONS_PLUGIN_DIR",
"STELLAOPS_PLUGIN_MIGRATIONS_DIR"
];
public static IReadOnlyList<MigrationModuleInfo> DiscoverModules()
{
LoadExternalPluginAssemblies();
var modulesByName = new Dictionary<string, MigrationModuleInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in DiscoverPlugins().OrderBy(static plugin => plugin.Module.Name, StringComparer.Ordinal))
{
var module = plugin.Module;
if (string.IsNullOrWhiteSpace(module.Name))
{
throw new InvalidOperationException(
$"Invalid migration module plugin '{plugin.GetType().FullName}': module name is required.");
}
if (string.IsNullOrWhiteSpace(module.SchemaName))
{
throw new InvalidOperationException(
$"Invalid migration module plugin '{plugin.GetType().FullName}': schema name is required.");
}
if (!modulesByName.TryAdd(module.Name, module))
{
throw new InvalidOperationException(
$"Duplicate migration module plug-in registration for module '{module.Name}'.");
}
}
var modules = modulesByName.Values
.OrderBy(static module => module.Name, StringComparer.Ordinal)
.ToArray();
if (modules.Length == 0)
{
throw new InvalidOperationException(
"No migration module plug-ins were discovered. " +
"Register at least one IMigrationModulePlugin implementation.");
}
return modules;
}
private static IEnumerable<IMigrationModulePlugin> DiscoverPlugins()
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies().OrderBy(static a => a.FullName, StringComparer.Ordinal))
{
if (assembly.IsDynamic)
{
continue;
}
foreach (var type in GetLoadableTypes(assembly))
{
if (!typeof(IMigrationModulePlugin).IsAssignableFrom(type) ||
type.IsAbstract ||
type.IsInterface ||
type.GetConstructor(Type.EmptyTypes) is null)
{
continue;
}
if (Activator.CreateInstance(type) is IMigrationModulePlugin plugin)
{
yield return plugin;
}
}
}
}
private static IEnumerable<Type> GetLoadableTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(static type => type is not null)!;
}
}
private static void LoadExternalPluginAssemblies()
{
foreach (var directory in ResolvePluginDirectories())
{
if (!Directory.Exists(directory))
{
continue;
}
foreach (var dllPath in Directory.EnumerateFiles(directory, "*.dll", SearchOption.TopDirectoryOnly)
.OrderBy(static path => path, StringComparer.Ordinal))
{
TryLoadAssembly(dllPath);
}
}
}
private static void TryLoadAssembly(string assemblyPath)
{
var fullPath = Path.GetFullPath(assemblyPath);
if (IsAssemblyAlreadyLoaded(fullPath))
{
return;
}
try
{
AssemblyLoadContext.Default.LoadFromAssemblyPath(fullPath);
}
catch (BadImageFormatException)
{
// Ignore native/non-.NET binaries in plugin directories.
}
catch (FileLoadException)
{
// Ignore already loaded or load-conflict assemblies.
}
}
private static bool IsAssemblyAlreadyLoaded(string fullPath) =>
AppDomain.CurrentDomain.GetAssemblies()
.Where(static assembly => !assembly.IsDynamic)
.Select(static assembly => assembly.Location)
.Any(location => string.Equals(location, fullPath, StringComparison.OrdinalIgnoreCase));
private static IReadOnlyList<string> ResolvePluginDirectories()
{
var directories = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "plugins", "migrations"))
};
foreach (var variable in PluginDirectoryEnvironmentVariables)
{
var value = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
foreach (var candidate in value.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
directories.Add(Path.GetFullPath(candidate));
}
}
return directories.OrderBy(static directory => directory, StringComparer.Ordinal).ToArray();
}
}

View File

@@ -0,0 +1,100 @@
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Authority.Persistence.Postgres;
using StellaOps.Concelier.Persistence.Postgres;
using StellaOps.Excititor.Persistence.Postgres;
using StellaOps.Notify.Persistence.Postgres;
using StellaOps.Policy.Persistence.Postgres;
using StellaOps.Scanner.Storage.Postgres;
using StellaOps.Scheduler.Persistence.Postgres;
using StellaOps.TimelineIndexer.Infrastructure;
namespace StellaOps.Platform.Database;
public sealed class AirGapMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "AirGap",
SchemaName: "airgap",
MigrationsAssembly: typeof(AirGapDataSource).Assembly);
}
public sealed class AuthorityMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Authority",
SchemaName: "authority",
MigrationsAssembly: typeof(AuthorityDataSource).Assembly,
ResourcePrefix: "StellaOps.Authority.Persistence.Migrations");
}
public sealed class SchedulerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Scheduler",
SchemaName: "scheduler",
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
ResourcePrefix: "StellaOps.Scheduler.Persistence.Migrations");
}
public sealed class ConcelierMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Concelier",
SchemaName: "vuln",
MigrationsAssembly: typeof(ConcelierDataSource).Assembly,
ResourcePrefix: "StellaOps.Concelier.Persistence.Migrations");
}
public sealed class PolicyMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Policy",
SchemaName: "policy",
MigrationsAssembly: typeof(PolicyDataSource).Assembly,
ResourcePrefix: "StellaOps.Policy.Persistence.Migrations");
}
public sealed class NotifyMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Notify",
SchemaName: "notify",
MigrationsAssembly: typeof(NotifyDataSource).Assembly,
ResourcePrefix: "StellaOps.Notify.Persistence.Migrations");
}
public sealed class ExcititorMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Excititor",
SchemaName: "vex",
MigrationsAssembly: typeof(ExcititorDataSource).Assembly,
ResourcePrefix: "StellaOps.Excititor.Persistence.Migrations");
}
public sealed class PlatformMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Platform",
SchemaName: "release",
MigrationsAssembly: typeof(ReleaseMigrationRunner).Assembly,
ResourcePrefix: "StellaOps.Platform.Database.Migrations.Release");
}
public sealed class ScannerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "Scanner",
SchemaName: "scanner",
MigrationsAssembly: typeof(ScannerDataSource).Assembly);
}
public sealed class TimelineIndexerMigrationModulePlugin : IMigrationModulePlugin
{
public MigrationModuleInfo Module { get; } = new(
Name: "TimelineIndexer",
SchemaName: "timeline",
MigrationsAssembly: typeof(TimelineIndexerDataSource).Assembly,
ResourcePrefix: "StellaOps.TimelineIndexer.Infrastructure.Db.Migrations");
}

View File

@@ -0,0 +1,53 @@
using System.Reflection;
using System.Threading;
namespace StellaOps.Platform.Database;
/// <summary>
/// Defines a PostgreSQL module with migration metadata.
/// </summary>
public sealed record MigrationModuleInfo(
string Name,
string SchemaName,
Assembly MigrationsAssembly,
string? ResourcePrefix = null);
/// <summary>
/// Canonical PostgreSQL migration module registry owned by Platform.
/// </summary>
public static class MigrationModuleRegistry
{
private static readonly Lazy<IReadOnlyList<MigrationModuleInfo>> _modules =
new(MigrationModulePluginDiscovery.DiscoverModules, LazyThreadSafetyMode.ExecutionAndPublication);
/// <summary>
/// Gets all registered modules.
/// </summary>
public static IReadOnlyList<MigrationModuleInfo> Modules => _modules.Value;
/// <summary>
/// Gets module names for completion and validation.
/// </summary>
public static IEnumerable<string> ModuleNames => Modules.Select(static module => module.Name);
/// <summary>
/// Finds a module by name (case-insensitive).
/// </summary>
public static MigrationModuleInfo? FindModule(string name) =>
Modules.FirstOrDefault(module =>
string.Equals(module.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Gets modules matching the filter, or all modules when null/empty/all.
/// </summary>
public static IEnumerable<MigrationModuleInfo> GetModules(string? moduleFilter)
{
if (string.IsNullOrWhiteSpace(moduleFilter) || moduleFilter.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return Modules;
}
var module = FindModule(moduleFilter);
return module is null ? [] : [module];
}
}

View File

@@ -11,6 +11,15 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\AirGap\__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj" />
<ProjectReference Include="..\..\..\Authority\__Libraries\StellaOps.Authority.Persistence\StellaOps.Authority.Persistence.csproj" />
<ProjectReference Include="..\..\..\Concelier\__Libraries\StellaOps.Concelier.Persistence\StellaOps.Concelier.Persistence.csproj" />
<ProjectReference Include="..\..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
<ProjectReference Include="..\..\..\Notify\__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj" />
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Persistence\StellaOps.Policy.Persistence.csproj" />
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Persistence\StellaOps.Scheduler.Persistence.csproj" />
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
</ItemGroup>

View File

@@ -4,6 +4,8 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-MGC-04-W1 | DONE | Added platform-owned `MigrationModuleRegistry` canonical module catalog for migration runner entrypoint consolidation; CLI now consumes this registry instead of owning module metadata. |
| SPRINT_20260222_051-MGC-04-W1-PLUGINS | DONE | Replaced hardcoded module catalog with auto-discovered migration plugins (`IMigrationModulePlugin`) so one consolidated plugin descriptor per web service feeds both CLI and Platform API migration execution paths. |
| B22-01-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `047_GlobalContextAndFilters.sql` with `platform.context_regions`, `platform.context_environments`, and `platform.ui_context_preferences`. |
| B22-02-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `048_ReleaseReadModels.sql` with release list/activity/approvals projection tables, correlation keys, and deterministic ordering indexes. |
| B22-03-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `049_TopologyInventory.sql` with normalized topology inventory projection tables and sync-watermark indexes. |

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using StellaOps.Platform.WebService.Options;
using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
@@ -89,7 +90,7 @@ public sealed class AnalyticsEndpointsSuccessTests : IClassFixture<PlatformWebAp
Assert.Equal("stage", response.Items[0].Environment);
}
private WebApplicationFactory<Program> CreateFactory(IPlatformAnalyticsQueryExecutor executor)
private WebApplicationFactory<PlatformServiceOptions> CreateFactory(IPlatformAnalyticsQueryExecutor executor)
{
return factory.WithWebHostBuilder(builder =>
{

View File

@@ -0,0 +1,142 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Platform.WebService.Tests;
public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private static readonly string[] ExpectedModules =
[
"AirGap",
"Authority",
"Concelier",
"Excititor",
"Notify",
"Platform",
"Policy",
"Scanner",
"Scheduler",
"TimelineIndexer"
];
private readonly PlatformWebApplicationFactory _factory;
public MigrationAdminEndpointsTests(PlatformWebApplicationFactory factory)
{
_factory = factory;
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Modules_Returns_Deterministic_PlatformRegistrySet()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", $"tenant-migration-modules-{Guid.NewGuid():N}");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-tester");
var response = await client.GetAsync(
"/api/v1/admin/migrations/modules",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var document = JsonDocument.Parse(
await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
var modules = document.RootElement
.GetProperty("modules")
.EnumerateArray()
.Select(static element => element.GetString())
.Where(static element => element is not null)
.Cast<string>()
.ToArray();
Assert.Equal(ExpectedModules, modules);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Status_WhenModuleUnknown_ReturnsBadRequestProblem()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", $"tenant-migration-status-unknown-{Guid.NewGuid():N}");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-tester");
var response = await client.GetAsync(
"/api/v1/admin/migrations/status?module=unknown-module",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(
TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Invalid module filter", problem!.Title);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Status_WhenConnectionMissing_ReturnsServiceUnavailableProblem()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", $"tenant-migration-status-missing-{Guid.NewGuid():N}");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-tester");
var response = await client.GetAsync(
"/api/v1/admin/migrations/status?module=all",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(
TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Database connection unavailable", problem!.Title);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Verify_WhenConnectionMissing_ReturnsServiceUnavailableProblem()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", $"tenant-migration-verify-missing-{Guid.NewGuid():N}");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-tester");
var response = await client.GetAsync(
"/api/v1/admin/migrations/verify?module=all",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(
TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Database connection unavailable", problem!.Title);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task Run_ReleaseWithoutForceAndDryRun_ReturnsBadRequestProblem()
{
using var client = _factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", $"tenant-migration-run-release-{Guid.NewGuid():N}");
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-tester");
var response = await client.PostAsJsonAsync(
"/api/v1/admin/migrations/run",
new { module = "all", category = "release", dryRun = false, force = false },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetails>(
TestContext.Current.CancellationToken);
Assert.NotNull(problem);
Assert.Equal("Release migration approval required", problem!.Title);
}
}

View File

@@ -10,10 +10,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Options;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PlatformWebApplicationFactory : WebApplicationFactory<Program>
public sealed class PlatformWebApplicationFactory : WebApplicationFactory<PlatformServiceOptions>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{

View File

@@ -14,6 +14,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Platform.WebService.Options;
using StellaOps.TestKit;
using Xunit;
@@ -54,7 +55,7 @@ public sealed class SeedEndpointsTests : IClassFixture<PlatformWebApplicationFac
[Fact]
public async Task SeedDemo_WhenModuleFilterMixesAllAndSpecific_ReturnsBadRequestProblem()
{
using WebApplicationFactory<Program> enabledFactory = _factory.WithWebHostBuilder(builder =>
using WebApplicationFactory<PlatformServiceOptions> enabledFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
@@ -91,7 +92,7 @@ public sealed class SeedEndpointsTests : IClassFixture<PlatformWebApplicationFac
[Fact]
public async Task SeedDemo_WhenConnectionMissing_ReturnsServiceUnavailableProblem()
{
using WebApplicationFactory<Program> enabledFactory = _factory.WithWebHostBuilder(builder =>
using WebApplicationFactory<PlatformServiceOptions> enabledFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
@@ -124,7 +125,7 @@ public sealed class SeedEndpointsTests : IClassFixture<PlatformWebApplicationFac
[Fact]
public async Task SeedDemo_WhenUnauthenticated_ReturnsUnauthorized()
{
using WebApplicationFactory<Program> unauthenticatedFactory = _factory.WithWebHostBuilder(builder =>
using WebApplicationFactory<PlatformServiceOptions> unauthenticatedFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
@@ -157,7 +158,7 @@ public sealed class SeedEndpointsTests : IClassFixture<PlatformWebApplicationFac
[Fact]
public async Task SeedDemo_WhenAuthorizationFails_ReturnsForbidden()
{
using WebApplicationFactory<Program> forbiddenFactory = _factory.WithWebHostBuilder(builder =>
using WebApplicationFactory<PlatformServiceOptions> forbiddenFactory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{

View File

@@ -5,6 +5,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| Task ID | Status | Notes |
| --- | --- | --- |
| SPRINT_20260222_051-MGC-12-T | DONE | Added `MigrationAdminEndpointsTests` covering module registry listing, module-filter validation, release-force guard, and no-connection service-unavailable responses for `/api/v1/admin/migrations/*`. |
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
| B22-01-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ContextEndpointsTests` + `ContextMigrationScriptTests` for `/api/v2/context/*` deterministic ordering, preference round-trip behavior, and migration `047` coverage. |