wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -19,7 +19,8 @@ public static class AdministrationTrustSigningMutationEndpoints
|
||||
public static IEndpointRouteBuilder MapAdministrationTrustSigningMutationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/administration/trust-signing")
|
||||
.WithTags("Administration");
|
||||
.WithTags("Administration")
|
||||
.RequireAuthorization(PlatformPolicies.TrustRead);
|
||||
|
||||
group.MapGet("/keys", async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -17,7 +17,8 @@ public static class AnalyticsEndpoints
|
||||
public static IEndpointRouteBuilder MapAnalyticsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var analytics = app.MapGroup("/api/analytics")
|
||||
.WithTags("Analytics");
|
||||
.WithTags("Analytics")
|
||||
.RequireAuthorization(PlatformPolicies.AnalyticsRead);
|
||||
|
||||
analytics.MapGet("/suppliers", async Task<IResult> (
|
||||
HttpContext context,
|
||||
|
||||
@@ -15,7 +15,8 @@ public static class ContextEndpoints
|
||||
public static IEndpointRouteBuilder MapContextEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var context = app.MapGroup("/api/v2/context")
|
||||
.WithTags("Platform Context");
|
||||
.WithTags("Platform Context")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
context.MapGet("/regions", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
|
||||
@@ -18,7 +18,8 @@ public static class EnvironmentSettingsAdminEndpoints
|
||||
public static IEndpointRouteBuilder MapEnvironmentSettingsAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/platform/envsettings/db")
|
||||
.WithTags("Environment Settings Admin");
|
||||
.WithTags("Environment Settings Admin")
|
||||
.RequireAuthorization(PlatformPolicies.SetupRead);
|
||||
|
||||
group.MapGet("/", async (IEnvironmentSettingsStore store, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.ReleaseOrchestrator.EvidenceThread.Export;
|
||||
using StellaOps.ReleaseOrchestrator.EvidenceThread.Models;
|
||||
@@ -31,13 +32,14 @@ public static class EvidenceThreadEndpoints
|
||||
public static IEndpointRouteBuilder MapEvidenceThreadEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var evidence = app.MapGroup("/api/v1/evidence")
|
||||
.WithTags("Evidence Thread");
|
||||
.WithTags("Evidence Thread")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
// GET /api/v1/evidence/{artifactDigest} - Get evidence thread for artifact
|
||||
evidence.MapGet("/{artifactDigest}", GetEvidenceThread)
|
||||
.WithName("GetEvidenceThread")
|
||||
.WithSummary("Get evidence thread for an artifact")
|
||||
.WithDescription("Retrieves the full evidence thread graph for an artifact by its digest.")
|
||||
.WithDescription("Retrieves the full evidence thread graph for an artifact by its digest, including node count, link count, verdict, and risk score.")
|
||||
.Produces<EvidenceThreadResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -46,7 +48,8 @@ public static class EvidenceThreadEndpoints
|
||||
evidence.MapPost("/{artifactDigest}/export", ExportEvidenceThread)
|
||||
.WithName("ExportEvidenceThread")
|
||||
.WithSummary("Export evidence thread as DSSE bundle")
|
||||
.WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification.")
|
||||
.WithDescription("Exports the evidence thread as a signed DSSE envelope for offline verification. Supports DSSE, JSON, Markdown, and PDF formats. The envelope is optionally signed with the specified key.")
|
||||
.RequireAuthorization(PlatformPolicies.ContextWrite)
|
||||
.Produces<EvidenceExportResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -55,7 +58,8 @@ public static class EvidenceThreadEndpoints
|
||||
evidence.MapPost("/{artifactDigest}/transcript", GenerateTranscript)
|
||||
.WithName("GenerateEvidenceTranscript")
|
||||
.WithSummary("Generate natural language transcript")
|
||||
.WithDescription("Generates a natural language transcript explaining the evidence thread.")
|
||||
.WithDescription("Generates a natural language transcript explaining the evidence thread in summary, detailed, or audit format. May invoke an LLM for rationale generation when enabled.")
|
||||
.RequireAuthorization(PlatformPolicies.ContextWrite)
|
||||
.Produces<EvidenceTranscriptResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -64,7 +68,7 @@ public static class EvidenceThreadEndpoints
|
||||
evidence.MapGet("/{artifactDigest}/nodes", GetEvidenceNodes)
|
||||
.WithName("GetEvidenceNodes")
|
||||
.WithSummary("Get evidence nodes for an artifact")
|
||||
.WithDescription("Retrieves all evidence nodes in the thread.")
|
||||
.WithDescription("Retrieves all evidence nodes in the thread, optionally filtered by node kind (e.g., sbom, scan, attestation). Returns node summaries, confidence scores, and anchor counts.")
|
||||
.Produces<EvidenceNodeListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -73,7 +77,7 @@ public static class EvidenceThreadEndpoints
|
||||
evidence.MapGet("/{artifactDigest}/links", GetEvidenceLinks)
|
||||
.WithName("GetEvidenceLinks")
|
||||
.WithSummary("Get evidence links for an artifact")
|
||||
.WithDescription("Retrieves all evidence links in the thread.")
|
||||
.WithDescription("Retrieves all directed evidence links in the thread, describing provenance and dependency relationships between evidence nodes.")
|
||||
.Produces<EvidenceLinkListResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
@@ -82,7 +86,8 @@ public static class EvidenceThreadEndpoints
|
||||
evidence.MapPost("/{artifactDigest}/collect", CollectEvidence)
|
||||
.WithName("CollectEvidence")
|
||||
.WithSummary("Collect evidence for an artifact")
|
||||
.WithDescription("Triggers collection of all available evidence for an artifact.")
|
||||
.WithDescription("Triggers collection of all available evidence for an artifact: SBOM diff, reachability graph, VEX advisories, and attestations. Returns the count of nodes and links created, plus any collection errors.")
|
||||
.RequireAuthorization(PlatformPolicies.ContextWrite)
|
||||
.Produces<EvidenceCollectionResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ public static class FederationTelemetryEndpoints
|
||||
public static IEndpointRouteBuilder MapFederationTelemetryEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/telemetry/federation")
|
||||
.WithTags("Federated Telemetry");
|
||||
.WithTags("Federated Telemetry")
|
||||
.RequireAuthorization(PlatformPolicies.FederationRead);
|
||||
|
||||
// GET /consent — get consent state
|
||||
group.MapGet("/consent", async Task<IResult>(
|
||||
|
||||
@@ -24,7 +24,8 @@ public static class FunctionMapEndpoints
|
||||
public static IEndpointRouteBuilder MapFunctionMapEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var maps = app.MapGroup("/api/v1/function-maps")
|
||||
.WithTags("Function Maps");
|
||||
.WithTags("Function Maps")
|
||||
.RequireAuthorization(PlatformPolicies.FunctionMapRead);
|
||||
|
||||
MapCrudEndpoints(maps);
|
||||
MapVerifyEndpoints(maps);
|
||||
|
||||
@@ -15,7 +15,8 @@ public static class IntegrationReadModelEndpoints
|
||||
public static IEndpointRouteBuilder MapIntegrationReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var integrations = app.MapGroup("/api/v2/integrations")
|
||||
.WithTags("Integrations V2");
|
||||
.WithTags("Integrations V2")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
|
||||
|
||||
integrations.MapGet("/feeds", async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -16,7 +16,8 @@ public static class LegacyAliasEndpoints
|
||||
public static IEndpointRouteBuilder MapLegacyAliasEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var legacy = app.MapGroup("/api/v1")
|
||||
.WithTags("Pack22 Legacy Aliases");
|
||||
.WithTags("Pack22 Legacy Aliases")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
legacy.MapGet("/context/regions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -43,7 +43,8 @@ public static class PackAdapterEndpoints
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
var platform = app.MapGroup("/api/v1/platform")
|
||||
.WithTags("Platform Ops");
|
||||
.WithTags("Platform Ops")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
platform.MapGet("/data-integrity/summary", (
|
||||
HttpContext context,
|
||||
|
||||
@@ -18,7 +18,8 @@ public static class PlatformEndpoints
|
||||
public static IEndpointRouteBuilder MapPlatformEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var platform = app.MapGroup("/api/v1/platform")
|
||||
.WithTags("Platform");
|
||||
.WithTags("Platform")
|
||||
.RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
MapHealthEndpoints(platform);
|
||||
MapQuotaEndpoints(platform);
|
||||
@@ -161,12 +162,12 @@ public static class PlatformEndpoints
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing" });
|
||||
return tenantFailure!;
|
||||
}
|
||||
|
||||
var result = await service.GetTenantAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
|
||||
var result = await service.GetTenantAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
@@ -293,12 +294,12 @@ public static class PlatformEndpoints
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing" });
|
||||
return tenantFailure!;
|
||||
}
|
||||
|
||||
var status = await service.GetTenantSetupStatusAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
|
||||
var status = await service.GetTenantSetupStatusAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(status);
|
||||
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
|
||||
}
|
||||
@@ -476,7 +477,8 @@ public static class PlatformEndpoints
|
||||
private static void MapLegacyQuotaCompatibilityEndpoints(IEndpointRouteBuilder app)
|
||||
{
|
||||
var quotas = app.MapGroup("/api/v1/authority/quotas")
|
||||
.WithTags("Platform Quotas Compatibility");
|
||||
.WithTags("Platform Quotas Compatibility")
|
||||
.RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet(string.Empty, async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -491,7 +493,7 @@ public static class PlatformEndpoints
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(BuildLegacyEntitlement(summary.Value, requestContext!));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/consumption", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -506,7 +508,7 @@ public static class PlatformEndpoints
|
||||
|
||||
var summary = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(BuildLegacyConsumption(summary.Value));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/dashboard", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -528,7 +530,7 @@ public static class PlatformEndpoints
|
||||
activeAlerts = 0,
|
||||
recentViolations = 0
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/history", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -570,7 +572,7 @@ public static class PlatformEndpoints
|
||||
points,
|
||||
aggregation = string.IsNullOrWhiteSpace(aggregation) ? "daily" : aggregation
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/tenants", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -612,7 +614,7 @@ public static class PlatformEndpoints
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new { items, total = 1 });
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -626,7 +628,12 @@ public static class PlatformEndpoints
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetTenantAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
if (!TryResolveRequestedTenant(requestContext!, tenantId, out var normalizedTenantId, out var tenantFailure))
|
||||
{
|
||||
return tenantFailure!;
|
||||
}
|
||||
|
||||
var result = await service.GetTenantAsync(normalizedTenantId, cancellationToken).ConfigureAwait(false);
|
||||
var consumption = BuildLegacyConsumption(result.Value);
|
||||
|
||||
return Results.Ok(new
|
||||
@@ -655,7 +662,7 @@ public static class PlatformEndpoints
|
||||
},
|
||||
forecast = BuildLegacyForecast("api")
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/forecast", async Task<IResult> (
|
||||
HttpContext context,
|
||||
@@ -673,7 +680,7 @@ public static class PlatformEndpoints
|
||||
|
||||
var forecasts = categories.Select(BuildLegacyForecast).ToArray();
|
||||
return Results.Ok(forecasts);
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/alerts", (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
@@ -694,7 +701,7 @@ public static class PlatformEndpoints
|
||||
channels = Array.Empty<object>(),
|
||||
escalationMinutes = 30
|
||||
}));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapPost("/alerts", (HttpContext context, PlatformRequestContextResolver resolver, [FromBody] object config) =>
|
||||
{
|
||||
@@ -704,10 +711,11 @@ public static class PlatformEndpoints
|
||||
}
|
||||
|
||||
return Task.FromResult<IResult>(Results.Ok(config));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
|
||||
|
||||
var rateLimits = app.MapGroup("/api/v1/gateway/rate-limits")
|
||||
.WithTags("Platform Gateway Compatibility");
|
||||
.WithTags("Platform Gateway Compatibility")
|
||||
.RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
rateLimits.MapGet(string.Empty, (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
@@ -729,7 +737,7 @@ public static class PlatformEndpoints
|
||||
burstRemaining = 119
|
||||
}
|
||||
}));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
rateLimits.MapGet("/violations", (HttpContext context, PlatformRequestContextResolver resolver) =>
|
||||
{
|
||||
@@ -749,7 +757,7 @@ public static class PlatformEndpoints
|
||||
end = now.ToString("o")
|
||||
}
|
||||
}));
|
||||
}).RequireAuthorization();
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
}
|
||||
|
||||
private static LegacyQuotaItem[] BuildLegacyConsumption(IReadOnlyList<PlatformQuotaUsage> usage)
|
||||
@@ -885,6 +893,37 @@ public static class PlatformEndpoints
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryResolveRequestedTenant(
|
||||
PlatformRequestContext requestContext,
|
||||
string? requestedTenantId,
|
||||
out string normalizedTenantId,
|
||||
out IResult? failure)
|
||||
{
|
||||
normalizedTenantId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requestedTenantId))
|
||||
{
|
||||
failure = Results.BadRequest(new { error = "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedTenantId = requestedTenantId.Trim().ToLowerInvariant();
|
||||
if (!string.Equals(normalizedTenantId, requestContext.TenantId, StringComparison.Ordinal))
|
||||
{
|
||||
failure = Results.Json(
|
||||
new
|
||||
{
|
||||
error = "tenant_forbidden",
|
||||
requestedTenantId = normalizedTenantId
|
||||
},
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
return false;
|
||||
}
|
||||
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed record LegacyQuotaItem(
|
||||
string Category,
|
||||
decimal Current,
|
||||
|
||||
@@ -25,7 +25,8 @@ public static class PolicyInteropEndpoints
|
||||
public static IEndpointRouteBuilder MapPolicyInteropEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var interop = app.MapGroup("/api/v1/policy/interop")
|
||||
.WithTags("PolicyInterop");
|
||||
.WithTags("PolicyInterop")
|
||||
.RequireAuthorization(PlatformPolicies.PolicyRead);
|
||||
|
||||
MapExportEndpoint(interop);
|
||||
MapImportEndpoint(interop);
|
||||
|
||||
@@ -19,7 +19,8 @@ public static class ReleaseControlEndpoints
|
||||
public static IEndpointRouteBuilder MapReleaseControlEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var bundles = app.MapGroup("/api/v1/release-control/bundles")
|
||||
.WithTags("Release Control");
|
||||
.WithTags("Release Control")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
bundles.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -16,7 +16,8 @@ public static class ReleaseReadModelEndpoints
|
||||
public static IEndpointRouteBuilder MapReleaseReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var releases = app.MapGroup("/api/v2/releases")
|
||||
.WithTags("Releases V2");
|
||||
.WithTags("Releases V2")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
releases.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -25,7 +25,8 @@ public static class ScoreEndpoints
|
||||
public static IEndpointRouteBuilder MapScoreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var score = app.MapGroup("/api/v1/score")
|
||||
.WithTags("Score");
|
||||
.WithTags("Score")
|
||||
.RequireAuthorization(PlatformPolicies.ScoreRead);
|
||||
|
||||
MapEvaluateEndpoints(score);
|
||||
MapHistoryEndpoints(score);
|
||||
|
||||
@@ -15,7 +15,8 @@ public static class SecurityReadModelEndpoints
|
||||
public static IEndpointRouteBuilder MapSecurityReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var security = app.MapGroup("/api/v2/security")
|
||||
.WithTags("Security V2");
|
||||
.WithTags("Security V2")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
security.MapGet("/findings", async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -15,7 +15,8 @@ public static class TopologyReadModelEndpoints
|
||||
public static IEndpointRouteBuilder MapTopologyReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var topology = app.MapGroup("/api/v2/topology")
|
||||
.WithTags("Topology V2");
|
||||
.WithTags("Topology V2")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/regions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.Database.Postgres;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -249,26 +251,9 @@ public sealed class InMemoryPlatformContextStore : IPlatformContextStore
|
||||
|
||||
public sealed class PostgresPlatformContextStore : IPlatformContextStore
|
||||
{
|
||||
private const string SelectRegionsSql = """
|
||||
SELECT region_id, display_name, sort_order, enabled
|
||||
FROM platform.context_regions
|
||||
WHERE enabled = true
|
||||
ORDER BY sort_order, region_id
|
||||
""";
|
||||
|
||||
private const string SelectEnvironmentsSql = """
|
||||
SELECT environment_id, region_id, environment_type, display_name, sort_order, enabled
|
||||
FROM platform.context_environments
|
||||
WHERE enabled = true
|
||||
ORDER BY sort_order, region_id, environment_id
|
||||
""";
|
||||
|
||||
private const string SelectPreferencesSql = """
|
||||
SELECT regions, environments, time_window, updated_at, updated_by
|
||||
FROM platform.ui_context_preferences
|
||||
WHERE tenant_id = @tenant_id AND actor_id = @actor_id
|
||||
""";
|
||||
private const int DefaultCommandTimeoutSeconds = 30;
|
||||
|
||||
// PostgreSQL-specific upsert with RETURNING for preferences.
|
||||
private const string UpsertPreferencesSql = """
|
||||
INSERT INTO platform.ui_context_preferences
|
||||
(tenant_id, actor_id, regions, environments, time_window, updated_at, updated_by)
|
||||
@@ -293,40 +278,51 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
|
||||
|
||||
public async Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var regions = new List<PlatformContextRegion>();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectRegionsSql, connection);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
regions.Add(new PlatformContextRegion(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetInt32(2),
|
||||
reader.GetBoolean(3)));
|
||||
}
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return regions;
|
||||
var entities = await dbContext.ContextRegions
|
||||
.AsNoTracking()
|
||||
.Where(r => r.Enabled)
|
||||
.OrderBy(r => r.SortOrder)
|
||||
.ThenBy(r => r.RegionId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities
|
||||
.Select(r => new PlatformContextRegion(
|
||||
r.RegionId,
|
||||
r.DisplayName,
|
||||
r.SortOrder,
|
||||
r.Enabled))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var environments = new List<PlatformContextEnvironment>();
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectEnvironmentsSql, connection);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
environments.Add(new PlatformContextEnvironment(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
reader.GetString(2),
|
||||
reader.GetString(3),
|
||||
reader.GetInt32(4),
|
||||
reader.GetBoolean(5)));
|
||||
}
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
return environments;
|
||||
var entities = await dbContext.ContextEnvironments
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Enabled)
|
||||
.OrderBy(e => e.SortOrder)
|
||||
.ThenBy(e => e.RegionId)
|
||||
.ThenBy(e => e.EnvironmentId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entities
|
||||
.Select(e => new PlatformContextEnvironment(
|
||||
e.EnvironmentId,
|
||||
e.RegionId,
|
||||
e.EnvironmentType,
|
||||
e.DisplayName,
|
||||
e.SortOrder,
|
||||
e.Enabled))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences?> GetPreferencesAsync(
|
||||
@@ -335,12 +331,15 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(SelectPreferencesSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
command.Parameters.AddWithValue("actor_id", actorId);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
var entity = await dbContext.UiContextPreferences
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.TenantId == tenantId && p.ActorId == actorId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -348,17 +347,18 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
|
||||
return new PlatformContextPreferences(
|
||||
tenantId,
|
||||
actorId,
|
||||
ReadTextArray(reader, 0),
|
||||
ReadTextArray(reader, 1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetString(4));
|
||||
NormalizeTextArray(entity.Regions),
|
||||
NormalizeTextArray(entity.Environments),
|
||||
entity.TimeWindow,
|
||||
new DateTimeOffset(DateTime.SpecifyKind(entity.UpdatedAt, DateTimeKind.Utc)),
|
||||
entity.UpdatedBy);
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
|
||||
PlatformContextPreferences preference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Keep raw SQL for PostgreSQL-specific ON CONFLICT ... RETURNING upsert.
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var command = new NpgsqlCommand(UpsertPreferencesSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", preference.TenantId);
|
||||
@@ -382,6 +382,21 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
|
||||
reader.GetString(4));
|
||||
}
|
||||
|
||||
private static string[] NormalizeTextArray(string[]? values)
|
||||
{
|
||||
if (values is null || values.Length == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string[] ReadTextArray(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Platform.Database;
|
||||
|
||||
@@ -19,7 +20,7 @@ internal sealed class PlatformMigrationAdminService
|
||||
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
||||
}
|
||||
|
||||
public Task<MigrationResult> RunAsync(
|
||||
public async Task<MigrationResult> RunAsync(
|
||||
MigrationModuleInfo module,
|
||||
MigrationCategory? category,
|
||||
bool dryRun,
|
||||
@@ -27,7 +28,20 @@ internal sealed class PlatformMigrationAdminService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
var options = new MigrationRunOptions
|
||||
{
|
||||
@@ -38,7 +52,40 @@ internal sealed class PlatformMigrationAdminService
|
||||
FailOnChecksumMismatch = true
|
||||
};
|
||||
|
||||
return runner.RunFromAssemblyAsync(module.MigrationsAssembly, module.ResourcePrefix, options, cancellationToken);
|
||||
if (appliedMigrations.Count == 0)
|
||||
{
|
||||
var result = await RunConsolidatedAsync(
|
||||
module,
|
||||
connectionString,
|
||||
consolidatedArtifact,
|
||||
options,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.Success && !options.DryRun)
|
||||
{
|
||||
await BackfillLegacyHistoryAsync(
|
||||
module,
|
||||
connectionString,
|
||||
consolidatedArtifact.SourceMigrations,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0 && !options.DryRun)
|
||||
{
|
||||
await BackfillLegacyHistoryAsync(
|
||||
module,
|
||||
connectionString,
|
||||
missingLegacyMigrations,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await RunAcrossSourcesAsync(module, connectionString, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<MigrationStatus> GetStatusAsync(
|
||||
@@ -46,29 +93,296 @@ internal sealed class PlatformMigrationAdminService
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
if (appliedMigrations.Count == 0 || (hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0))
|
||||
{
|
||||
return BuildConsolidatedStatus(module, consolidatedArtifact, appliedMigrations, consolidatedApplied);
|
||||
}
|
||||
|
||||
var logger = _loggerFactory.CreateLogger($"platform.migrationstatus.{module.Name}");
|
||||
var sources = module.Sources
|
||||
.Select(static source => new MigrationAssemblySource(source.MigrationsAssembly, source.ResourcePrefix))
|
||||
.ToArray();
|
||||
var statusService = new MigrationStatusService(
|
||||
connectionString,
|
||||
module.SchemaName,
|
||||
module.Name,
|
||||
module.MigrationsAssembly,
|
||||
sources,
|
||||
logger);
|
||||
|
||||
return await statusService.GetStatusAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> VerifyAsync(
|
||||
public async Task<IReadOnlyList<string>> VerifyAsync(
|
||||
MigrationModuleInfo module,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = ResolveConnectionString(module);
|
||||
var consolidatedArtifact = MigrationModuleConsolidation.Build(module);
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
return runner.ValidateChecksumsAsync(module.MigrationsAssembly, module.ResourcePrefix, cancellationToken);
|
||||
var appliedMigrations = await runner
|
||||
.GetAppliedMigrationInfoAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
var hasConsolidatedApplied = appliedMigrations.Any(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal));
|
||||
var consolidatedApplied = hasConsolidatedApplied
|
||||
? appliedMigrations.First(migration =>
|
||||
string.Equals(migration.Name, consolidatedArtifact.MigrationName, StringComparison.Ordinal))
|
||||
: (MigrationInfo?)null;
|
||||
var missingLegacyMigrations = GetMissingLegacyMigrations(consolidatedArtifact, appliedMigrations);
|
||||
var consolidatedInSync = IsConsolidatedInSync(consolidatedArtifact, consolidatedApplied);
|
||||
|
||||
if (appliedMigrations.Count > 0 && hasConsolidatedApplied && consolidatedInSync && missingLegacyMigrations.Count > 0)
|
||||
{
|
||||
return ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value);
|
||||
}
|
||||
|
||||
var errors = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (hasConsolidatedApplied)
|
||||
{
|
||||
foreach (var error in ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied!.Value))
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
var sourceErrors = await runner
|
||||
.ValidateChecksumsAsync(source.MigrationsAssembly, source.ResourcePrefix, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var error in sourceErrors)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors.OrderBy(static error => error, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ValidateConsolidatedChecksum(
|
||||
MigrationModuleConsolidatedArtifact artifact,
|
||||
MigrationInfo appliedMigration)
|
||||
{
|
||||
if (string.Equals(appliedMigration.Checksum, artifact.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
$"Checksum mismatch for '{artifact.MigrationName}': expected '{artifact.Checksum[..16]}...', found '{appliedMigration.Checksum[..16]}...'"
|
||||
];
|
||||
}
|
||||
|
||||
private async Task<MigrationResult> RunConsolidatedAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
MigrationRunOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tempRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"stellaops-migrations",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
var migrationPath = Path.Combine(tempRoot, consolidatedArtifact.MigrationName);
|
||||
|
||||
await File.WriteAllTextAsync(migrationPath, consolidatedArtifact.Script, cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
return await runner.RunAsync(tempRoot, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteDirectory(tempRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task BackfillLegacyHistoryAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> migrationsToBackfill,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (migrationsToBackfill.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var schemaName = QuoteIdentifier(module.SchemaName);
|
||||
var sql = $"""
|
||||
INSERT INTO {schemaName}.schema_migrations (migration_name, category, checksum, applied_by, duration_ms)
|
||||
VALUES (@name, @category, @checksum, @appliedBy, @durationMs)
|
||||
ON CONFLICT (migration_name) DO NOTHING;
|
||||
""";
|
||||
|
||||
await using var command = new NpgsqlCommand(sql, connection);
|
||||
var nameParam = command.Parameters.Add("name", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var categoryParam = command.Parameters.Add("category", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var checksumParam = command.Parameters.Add("checksum", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var appliedByParam = command.Parameters.Add("appliedBy", NpgsqlTypes.NpgsqlDbType.Text);
|
||||
var durationParam = command.Parameters.Add("durationMs", NpgsqlTypes.NpgsqlDbType.Integer);
|
||||
|
||||
foreach (var migration in migrationsToBackfill)
|
||||
{
|
||||
nameParam.Value = migration.Name;
|
||||
categoryParam.Value = migration.Category.ToString().ToLowerInvariant();
|
||||
checksumParam.Value = migration.Checksum;
|
||||
appliedByParam.Value = Environment.MachineName;
|
||||
durationParam.Value = 0;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static MigrationStatus BuildConsolidatedStatus(
|
||||
MigrationModuleInfo module,
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
IReadOnlyList<MigrationInfo> appliedMigrations,
|
||||
MigrationInfo? consolidatedApplied)
|
||||
{
|
||||
var pending = consolidatedApplied is null
|
||||
? new[] { new PendingMigrationInfo(consolidatedArtifact.MigrationName, MigrationCategory.Release) }
|
||||
: [];
|
||||
var checksumErrors = consolidatedApplied is null
|
||||
? []
|
||||
: ValidateConsolidatedChecksum(consolidatedArtifact, consolidatedApplied.Value);
|
||||
var lastApplied = appliedMigrations
|
||||
.OrderByDescending(static migration => migration.AppliedAt)
|
||||
.FirstOrDefault();
|
||||
|
||||
return new MigrationStatus
|
||||
{
|
||||
ModuleName = module.Name,
|
||||
SchemaName = module.SchemaName,
|
||||
AppliedCount = appliedMigrations.Count,
|
||||
PendingStartupCount = 0,
|
||||
PendingReleaseCount = pending.Length,
|
||||
LastAppliedMigration = lastApplied.Name,
|
||||
LastAppliedAt = lastApplied.Name is null ? null : lastApplied.AppliedAt,
|
||||
PendingMigrations = pending,
|
||||
ChecksumErrors = checksumErrors
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<MigrationResult> RunAcrossSourcesAsync(
|
||||
MigrationModuleInfo module,
|
||||
string connectionString,
|
||||
MigrationRunOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<MigrationResult>(module.Sources.Count);
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
var runner = CreateRunner(module, connectionString);
|
||||
var result = await runner
|
||||
.RunFromAssemblyAsync(source.MigrationsAssembly, source.ResourcePrefix, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return AggregateRunResults(results);
|
||||
}
|
||||
|
||||
private static MigrationResult AggregateRunResults(IReadOnlyList<MigrationResult> results)
|
||||
{
|
||||
if (results.Count == 0)
|
||||
{
|
||||
return MigrationResult.Successful(0, 0, 0, 0, []);
|
||||
}
|
||||
|
||||
if (results.Count == 1)
|
||||
{
|
||||
return results[0];
|
||||
}
|
||||
|
||||
var firstFailure = results.FirstOrDefault(static result => !result.Success);
|
||||
return new MigrationResult
|
||||
{
|
||||
Success = firstFailure is null,
|
||||
AppliedCount = results.Sum(static result => result.AppliedCount),
|
||||
SkippedCount = results.Max(static result => result.SkippedCount),
|
||||
FilteredCount = results.Sum(static result => result.FilteredCount),
|
||||
DurationMs = results.Sum(static result => result.DurationMs),
|
||||
AppliedMigrations = results.SelectMany(static result => result.AppliedMigrations).ToArray(),
|
||||
ChecksumErrors = results
|
||||
.SelectMany(static result => result.ChecksumErrors)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(static error => error, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
ErrorMessage = firstFailure?.ErrorMessage
|
||||
};
|
||||
}
|
||||
|
||||
private MigrationRunner CreateRunner(MigrationModuleInfo module, string connectionString) =>
|
||||
new(connectionString, module.SchemaName, module.Name, _loggerFactory.CreateLogger($"platform.migration.{module.Name}"));
|
||||
|
||||
private static string QuoteIdentifier(string identifier)
|
||||
{
|
||||
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);
|
||||
return $"\"{escaped}\"";
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MigrationModuleConsolidatedSourceMigration> GetMissingLegacyMigrations(
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
IReadOnlyList<MigrationInfo> appliedMigrations)
|
||||
{
|
||||
var appliedNames = appliedMigrations
|
||||
.Select(static migration => migration.Name)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
return consolidatedArtifact.SourceMigrations
|
||||
.Where(migration => !appliedNames.Contains(migration.Name))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool IsConsolidatedInSync(
|
||||
MigrationModuleConsolidatedArtifact consolidatedArtifact,
|
||||
MigrationInfo? consolidatedApplied) =>
|
||||
consolidatedApplied is not null &&
|
||||
string.Equals(consolidatedApplied.Value.Checksum, consolidatedArtifact.Checksum, StringComparison.Ordinal);
|
||||
|
||||
private string ResolveConnectionString(MigrationModuleInfo module)
|
||||
{
|
||||
var envCandidates = new[]
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class PlatformRequestContextResolver
|
||||
{
|
||||
private const string LegacyTenantClaim = "tid";
|
||||
private const string LegacyTenantHeader = "X-Stella-Tenant";
|
||||
private const string ProjectHeader = "X-Stella-Project";
|
||||
private const string ActorHeader = "X-StellaOps-Actor";
|
||||
@@ -16,9 +17,8 @@ public sealed class PlatformRequestContextResolver
|
||||
requestContext = null;
|
||||
error = null;
|
||||
|
||||
if (!TryResolveTenant(context, out var tenantId))
|
||||
if (!TryResolveTenant(context, out var tenantId, out error))
|
||||
{
|
||||
error = "tenant_missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,29 +29,46 @@ public sealed class PlatformRequestContextResolver
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId)
|
||||
private static bool TryResolveTenant(HttpContext context, out string tenantId, out string? error)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
error = null;
|
||||
|
||||
var claimTenant = context.User.FindFirstValue(StellaOpsClaimTypes.Tenant);
|
||||
var claimTenant = NormalizeTenant(
|
||||
context.User.FindFirstValue(StellaOpsClaimTypes.Tenant)
|
||||
?? context.User.FindFirstValue(LegacyTenantClaim));
|
||||
var canonicalHeaderTenant = ReadTenantHeader(context, StellaOpsHttpHeaderNames.Tenant);
|
||||
var legacyHeaderTenant = ReadTenantHeader(context, LegacyTenantHeader);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(canonicalHeaderTenant) &&
|
||||
!string.IsNullOrWhiteSpace(legacyHeaderTenant) &&
|
||||
!string.Equals(canonicalHeaderTenant, legacyHeaderTenant, StringComparison.Ordinal))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
var headerTenant = canonicalHeaderTenant ?? legacyHeaderTenant;
|
||||
if (!string.IsNullOrWhiteSpace(claimTenant))
|
||||
{
|
||||
tenantId = claimTenant.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant) &&
|
||||
!string.Equals(claimTenant, headerTenant, StringComparison.Ordinal))
|
||||
{
|
||||
error = "tenant_conflict";
|
||||
return false;
|
||||
}
|
||||
|
||||
tenantId = claimTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveHeader(context, StellaOpsHttpHeaderNames.Tenant, out tenantId))
|
||||
if (!string.IsNullOrWhiteSpace(headerTenant))
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryResolveHeader(context, LegacyTenantHeader, out tenantId))
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
tenantId = headerTenant;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = "tenant_missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -110,4 +127,18 @@ public sealed class PlatformRequestContextResolver
|
||||
value = raw.Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? ReadTenantHeader(HttpContext context, string headerName)
|
||||
{
|
||||
return TryResolveHeader(context, headerName, out var value)
|
||||
? NormalizeTenant(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeTenant(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.Database.EfCore.Context;
|
||||
using StellaOps.Platform.Database.Postgres;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
@@ -10,6 +13,7 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
/// 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"/>.
|
||||
/// Uses EF Core for read operations and raw SQL for PostgreSQL-specific upsert.
|
||||
/// </summary>
|
||||
public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
{
|
||||
@@ -18,22 +22,12 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
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 int DefaultCommandTimeoutSeconds = 30;
|
||||
|
||||
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
|
||||
VALUES ({0}, {1}, now(), {2})
|
||||
ON CONFLICT (key) DO UPDATE SET value = {1}, updated_at = now(), updated_by = {2}
|
||||
""";
|
||||
|
||||
public PostgresEnvironmentSettingsStore(
|
||||
@@ -52,20 +46,25 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
|
||||
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);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
var entities = await dbContext.EnvironmentSettings
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Key)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
dict[reader.GetString(0)] = reader.GetString(1);
|
||||
dict[entity.Key] = entity.Value;
|
||||
}
|
||||
|
||||
var result = dict;
|
||||
lock (_cacheLock)
|
||||
{
|
||||
_cache ??= result;
|
||||
_cache ??= dict;
|
||||
}
|
||||
|
||||
return _cache;
|
||||
@@ -86,13 +85,16 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
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 using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
// Use raw SQL for PostgreSQL-specific ON CONFLICT upsert with server-side now().
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
UpsertSql,
|
||||
[key, value, updatedBy],
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation("Environment setting {Key} updated by {UpdatedBy}", key, updatedBy);
|
||||
@@ -103,14 +105,26 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
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);
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var rows = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
InvalidateCache();
|
||||
var entity = await dbContext.EnvironmentSettings
|
||||
.FirstOrDefaultAsync(e => e.Key == key, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation("Environment setting {Key} deleted ({Rows} rows affected)", key, rows);
|
||||
if (entity is not null)
|
||||
{
|
||||
dbContext.EnvironmentSettings.Remove(entity);
|
||||
var rows = await dbContext.SaveChangesAsync(ct).ConfigureAwait(false);
|
||||
InvalidateCache();
|
||||
|
||||
_logger.LogInformation("Environment setting {Key} deleted ({Rows} rows affected)", key, rows);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Environment setting {Key} not found for deletion", key);
|
||||
}
|
||||
}
|
||||
|
||||
public void InvalidateCache()
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,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_20260222_051-MGC-12-SOURCES | DONE | Platform migration admin service now executes and verifies migrations across per-service plugin source sets, applies synthesized per-plugin consolidated migration on empty history with legacy history backfill, and auto-heals partial backfill states before per-source execution. |
|
||||
| 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`. |
|
||||
@@ -34,5 +35,5 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| TASK-030-013 | BLOCKED | Attestation coverage view delivered; validation blocked pending ingestion datasets. |
|
||||
| TASK-030-017 | BLOCKED | Stored procedures delivered; validation blocked pending ingestion datasets. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
|
||||
| PLATFORM-EF-03-WS | DONE | Sprint `docs/implplan/SPRINT_20260222_096_Platform_dal_to_efcore.md`: converted `PostgresEnvironmentSettingsStore` and `PostgresPlatformContextStore` to EF Core LINQ reads with `AsNoTracking()`, raw SQL upserts. Added `Microsoft.EntityFrameworkCore` package reference. |
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class ContextEnvironmentEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Platform.Database.EfCore.Models.ContextEnvironment",
|
||||
typeof(ContextEnvironment),
|
||||
baseEntityType);
|
||||
|
||||
var environmentId = runtimeEntityType.AddProperty(
|
||||
"EnvironmentId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("EnvironmentId",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
environmentId.AddAnnotation("Relational:ColumnName", "environment_id");
|
||||
|
||||
var regionId = runtimeEntityType.AddProperty(
|
||||
"RegionId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("RegionId",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
regionId.AddAnnotation("Relational:ColumnName", "region_id");
|
||||
|
||||
var environmentType = runtimeEntityType.AddProperty(
|
||||
"EnvironmentType",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("EnvironmentType",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
environmentType.AddAnnotation("Relational:ColumnName", "environment_type");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("DisplayName",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var sortOrder = runtimeEntityType.AddProperty(
|
||||
"SortOrder",
|
||||
typeof(int),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("SortOrder",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
sortOrder.AddAnnotation("Relational:ColumnName", "sort_order");
|
||||
|
||||
var enabled = runtimeEntityType.AddProperty(
|
||||
"Enabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ContextEnvironment).GetProperty("Enabled",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
enabled.AddAnnotation("Relational:ColumnName", "enabled");
|
||||
enabled.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var pk = runtimeEntityType.AddKey(new[] { environmentId });
|
||||
pk.AddAnnotation("Relational:Name", "context_environments_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(pk);
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { regionId, sortOrder, environmentId },
|
||||
"ix_platform_context_environments_region_sort");
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { sortOrder, regionId, environmentId },
|
||||
"ix_platform_context_environments_sort");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "context_environments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class ContextRegionEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Platform.Database.EfCore.Models.ContextRegion",
|
||||
typeof(ContextRegion),
|
||||
baseEntityType);
|
||||
|
||||
var regionId = runtimeEntityType.AddProperty(
|
||||
"RegionId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextRegion).GetProperty("RegionId",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
regionId.AddAnnotation("Relational:ColumnName", "region_id");
|
||||
|
||||
var displayName = runtimeEntityType.AddProperty(
|
||||
"DisplayName",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(ContextRegion).GetProperty("DisplayName",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
displayName.AddAnnotation("Relational:ColumnName", "display_name");
|
||||
|
||||
var sortOrder = runtimeEntityType.AddProperty(
|
||||
"SortOrder",
|
||||
typeof(int),
|
||||
propertyInfo: typeof(ContextRegion).GetProperty("SortOrder",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
sortOrder.AddAnnotation("Relational:ColumnName", "sort_order");
|
||||
|
||||
var enabled = runtimeEntityType.AddProperty(
|
||||
"Enabled",
|
||||
typeof(bool),
|
||||
propertyInfo: typeof(ContextRegion).GetProperty("Enabled",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
enabled.AddAnnotation("Relational:ColumnName", "enabled");
|
||||
enabled.AddAnnotation("Relational:DefaultValue", true);
|
||||
|
||||
var pk = runtimeEntityType.AddKey(new[] { regionId });
|
||||
pk.AddAnnotation("Relational:Name", "context_regions_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(pk);
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { sortOrder, regionId },
|
||||
"ux_platform_context_regions_sort",
|
||||
unique: true);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "context_regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class EnvironmentSettingEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Platform.Database.EfCore.Models.EnvironmentSetting",
|
||||
typeof(EnvironmentSetting),
|
||||
baseEntityType);
|
||||
|
||||
var key = runtimeEntityType.AddProperty(
|
||||
"Key",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(EnvironmentSetting).GetProperty("Key",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
key.AddAnnotation("Relational:ColumnName", "key");
|
||||
|
||||
var value = runtimeEntityType.AddProperty(
|
||||
"Value",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(EnvironmentSetting).GetProperty("Value",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
value.AddAnnotation("Relational:ColumnName", "value");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(EnvironmentSetting).GetProperty("UpdatedAt",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var updatedBy = runtimeEntityType.AddProperty(
|
||||
"UpdatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(EnvironmentSetting).GetProperty("UpdatedBy",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
|
||||
updatedBy.AddAnnotation("Relational:DefaultValueSql", "'system'");
|
||||
|
||||
var pk = runtimeEntityType.AddKey(new[] { key });
|
||||
pk.AddAnnotation("Relational:Name", "environment_settings_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(pk);
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "environment_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using StellaOps.Platform.Database.EfCore.CompiledModels;
|
||||
using StellaOps.Platform.Database.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
[assembly: DbContextModel(typeof(PlatformDbContext), typeof(PlatformDbContextModel))]
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using StellaOps.Platform.Database.EfCore.Context;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
[DbContext(typeof(PlatformDbContext))]
|
||||
public partial class PlatformDbContextModel : RuntimeModel
|
||||
{
|
||||
private static readonly bool _useOldBehavior31751 =
|
||||
System.AppContext.TryGetSwitch("Microsoft.EntityFrameworkCore.Issue31751", out var enabled31751) && enabled31751;
|
||||
|
||||
static PlatformDbContextModel()
|
||||
{
|
||||
var model = new PlatformDbContextModel();
|
||||
|
||||
if (_useOldBehavior31751)
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
var thread = new System.Threading.Thread(RunInitialization, 10 * 1024 * 1024);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
void RunInitialization()
|
||||
{
|
||||
model.Initialize();
|
||||
}
|
||||
}
|
||||
|
||||
model.Customize();
|
||||
_instance = (PlatformDbContextModel)model.FinalizeModel();
|
||||
}
|
||||
|
||||
private static PlatformDbContextModel _instance;
|
||||
public static IModel Instance => _instance;
|
||||
|
||||
partial void Initialize();
|
||||
|
||||
partial void Customize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
public partial class PlatformDbContextModel
|
||||
{
|
||||
private PlatformDbContextModel()
|
||||
: base(skipDetectChanges: false, modelId: new Guid("b2a4e6c8-1d3f-4a5b-9c7e-0f8a2b4d6e10"), entityTypeCount: 4)
|
||||
{
|
||||
}
|
||||
|
||||
partial void Initialize()
|
||||
{
|
||||
var environmentSetting = EnvironmentSettingEntityType.Create(this);
|
||||
var contextRegion = ContextRegionEntityType.Create(this);
|
||||
var contextEnvironment = ContextEnvironmentEntityType.Create(this);
|
||||
var uiContextPreference = UiContextPreferenceEntityType.Create(this);
|
||||
|
||||
EnvironmentSettingEntityType.CreateAnnotations(environmentSetting);
|
||||
ContextRegionEntityType.CreateAnnotations(contextRegion);
|
||||
ContextEnvironmentEntityType.CreateAnnotations(contextEnvironment);
|
||||
UiContextPreferenceEntityType.CreateAnnotations(uiContextPreference);
|
||||
|
||||
AddAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
|
||||
AddAnnotation("ProductVersion", "10.0.0");
|
||||
AddAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
#pragma warning disable 219, 612, 618
|
||||
#nullable disable
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.CompiledModels
|
||||
{
|
||||
internal partial class UiContextPreferenceEntityType
|
||||
{
|
||||
public static RuntimeEntityType Create(RuntimeModel model, RuntimeEntityType baseEntityType = null)
|
||||
{
|
||||
var runtimeEntityType = model.AddEntityType(
|
||||
"StellaOps.Platform.Database.EfCore.Models.UiContextPreference",
|
||||
typeof(UiContextPreference),
|
||||
baseEntityType);
|
||||
|
||||
var tenantId = runtimeEntityType.AddProperty(
|
||||
"TenantId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("TenantId",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
tenantId.AddAnnotation("Relational:ColumnName", "tenant_id");
|
||||
|
||||
var actorId = runtimeEntityType.AddProperty(
|
||||
"ActorId",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("ActorId",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
actorId.AddAnnotation("Relational:ColumnName", "actor_id");
|
||||
|
||||
var regions = runtimeEntityType.AddProperty(
|
||||
"Regions",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("Regions",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
regions.AddAnnotation("Relational:ColumnName", "regions");
|
||||
regions.AddAnnotation("Relational:DefaultValueSql", "ARRAY[]::text[]");
|
||||
|
||||
var environments = runtimeEntityType.AddProperty(
|
||||
"Environments",
|
||||
typeof(string[]),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("Environments",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
environments.AddAnnotation("Relational:ColumnName", "environments");
|
||||
environments.AddAnnotation("Relational:DefaultValueSql", "ARRAY[]::text[]");
|
||||
|
||||
var timeWindow = runtimeEntityType.AddProperty(
|
||||
"TimeWindow",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("TimeWindow",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
timeWindow.AddAnnotation("Relational:ColumnName", "time_window");
|
||||
timeWindow.AddAnnotation("Relational:DefaultValueSql", "'24h'");
|
||||
|
||||
var updatedAt = runtimeEntityType.AddProperty(
|
||||
"UpdatedAt",
|
||||
typeof(DateTime),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("UpdatedAt",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
updatedAt.AddAnnotation("Relational:ColumnName", "updated_at");
|
||||
updatedAt.AddAnnotation("Relational:DefaultValueSql", "now()");
|
||||
|
||||
var updatedBy = runtimeEntityType.AddProperty(
|
||||
"UpdatedBy",
|
||||
typeof(string),
|
||||
propertyInfo: typeof(UiContextPreference).GetProperty("UpdatedBy",
|
||||
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
|
||||
fieldInfo: null);
|
||||
updatedBy.AddAnnotation("Relational:ColumnName", "updated_by");
|
||||
updatedBy.AddAnnotation("Relational:DefaultValueSql", "'system'");
|
||||
|
||||
var pk = runtimeEntityType.AddKey(new[] { tenantId, actorId });
|
||||
pk.AddAnnotation("Relational:Name", "ui_context_preferences_pkey");
|
||||
runtimeEntityType.SetPrimaryKey(pk);
|
||||
|
||||
runtimeEntityType.AddIndex(new[] { updatedAt, tenantId, actorId },
|
||||
"ix_platform_ui_context_preferences_updated");
|
||||
|
||||
return runtimeEntityType;
|
||||
}
|
||||
|
||||
public static void CreateAnnotations(RuntimeEntityType runtimeEntityType)
|
||||
{
|
||||
runtimeEntityType.AddAnnotation("Relational:Schema", "platform");
|
||||
runtimeEntityType.AddAnnotation("Relational:TableName", "ui_context_preferences");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Context;
|
||||
|
||||
public partial class PlatformDbContext : DbContext
|
||||
{
|
||||
private readonly string _schemaName;
|
||||
|
||||
public PlatformDbContext(DbContextOptions<PlatformDbContext> options, string? schemaName = null)
|
||||
: base(options)
|
||||
{
|
||||
_schemaName = string.IsNullOrWhiteSpace(schemaName)
|
||||
? "platform"
|
||||
: schemaName.Trim();
|
||||
}
|
||||
|
||||
public virtual DbSet<EnvironmentSetting> EnvironmentSettings { get; set; }
|
||||
|
||||
public virtual DbSet<ContextRegion> ContextRegions { get; set; }
|
||||
|
||||
public virtual DbSet<ContextEnvironment> ContextEnvironments { get; set; }
|
||||
|
||||
public virtual DbSet<UiContextPreference> UiContextPreferences { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
|
||||
modelBuilder.Entity<EnvironmentSetting>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Key).HasName("environment_settings_pkey");
|
||||
|
||||
entity.ToTable("environment_settings", schemaName);
|
||||
|
||||
entity.Property(e => e.Key).HasColumnName("key");
|
||||
entity.Property(e => e.Value).HasColumnName("value");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("updated_at");
|
||||
entity.Property(e => e.UpdatedBy)
|
||||
.HasDefaultValueSql("'system'")
|
||||
.HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContextRegion>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.RegionId).HasName("context_regions_pkey");
|
||||
|
||||
entity.ToTable("context_regions", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.SortOrder, e.RegionId }, "ux_platform_context_regions_sort")
|
||||
.IsUnique();
|
||||
|
||||
entity.Property(e => e.RegionId).HasColumnName("region_id");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.SortOrder).HasColumnName("sort_order");
|
||||
entity.Property(e => e.Enabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ContextEnvironment>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.EnvironmentId).HasName("context_environments_pkey");
|
||||
|
||||
entity.ToTable("context_environments", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.RegionId, e.SortOrder, e.EnvironmentId },
|
||||
"ix_platform_context_environments_region_sort");
|
||||
|
||||
entity.HasIndex(e => new { e.SortOrder, e.RegionId, e.EnvironmentId },
|
||||
"ix_platform_context_environments_sort");
|
||||
|
||||
entity.Property(e => e.EnvironmentId).HasColumnName("environment_id");
|
||||
entity.Property(e => e.RegionId).HasColumnName("region_id");
|
||||
entity.Property(e => e.EnvironmentType).HasColumnName("environment_type");
|
||||
entity.Property(e => e.DisplayName).HasColumnName("display_name");
|
||||
entity.Property(e => e.SortOrder).HasColumnName("sort_order");
|
||||
entity.Property(e => e.Enabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UiContextPreference>(entity =>
|
||||
{
|
||||
entity.HasKey(e => new { e.TenantId, e.ActorId }).HasName("ui_context_preferences_pkey");
|
||||
|
||||
entity.ToTable("ui_context_preferences", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.UpdatedAt, e.TenantId, e.ActorId },
|
||||
"ix_platform_ui_context_preferences_updated")
|
||||
.IsDescending(true, false, false);
|
||||
|
||||
entity.Property(e => e.TenantId).HasColumnName("tenant_id");
|
||||
entity.Property(e => e.ActorId).HasColumnName("actor_id");
|
||||
entity.Property(e => e.Regions)
|
||||
.HasDefaultValueSql("ARRAY[]::text[]")
|
||||
.HasColumnName("regions");
|
||||
entity.Property(e => e.Environments)
|
||||
.HasDefaultValueSql("ARRAY[]::text[]")
|
||||
.HasColumnName("environments");
|
||||
entity.Property(e => e.TimeWindow)
|
||||
.HasDefaultValueSql("'24h'")
|
||||
.HasColumnName("time_window");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("updated_at");
|
||||
entity.Property(e => e.UpdatedBy)
|
||||
.HasDefaultValueSql("'system'")
|
||||
.HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Context;
|
||||
|
||||
public sealed class PlatformDesignTimeDbContextFactory : IDesignTimeDbContextFactory<PlatformDbContext>
|
||||
{
|
||||
private const string DefaultConnectionString = "Host=localhost;Port=55434;Database=postgres;Username=postgres;Password=postgres;Search Path=platform,public";
|
||||
private const string ConnectionStringEnvironmentVariable = "STELLAOPS_PLATFORM_EF_CONNECTION";
|
||||
|
||||
public PlatformDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString = ResolveConnectionString();
|
||||
var options = new DbContextOptionsBuilder<PlatformDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new PlatformDbContext(options);
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString()
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable);
|
||||
return string.IsNullOrWhiteSpace(fromEnvironment) ? DefaultConnectionString : fromEnvironment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class ContextEnvironment
|
||||
{
|
||||
public string EnvironmentId { get; set; } = null!;
|
||||
|
||||
public string RegionId { get; set; } = null!;
|
||||
|
||||
public string EnvironmentType { get; set; } = null!;
|
||||
|
||||
public string DisplayName { get; set; } = null!;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class ContextRegion
|
||||
{
|
||||
public string RegionId { get; set; } = null!;
|
||||
|
||||
public string DisplayName { get; set; } = null!;
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class EnvironmentSetting
|
||||
{
|
||||
public string Key { get; set; } = null!;
|
||||
|
||||
public string Value { get; set; } = null!;
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public string UpdatedBy { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class UiContextPreference
|
||||
{
|
||||
public string TenantId { get; set; } = null!;
|
||||
|
||||
public string ActorId { get; set; } = null!;
|
||||
|
||||
public string[] Regions { get; set; } = [];
|
||||
|
||||
public string[] Environments { get; set; } = [];
|
||||
|
||||
public string TimeWindow { get; set; } = null!;
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public string UpdatedBy { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Consolidated migration artifact generated from all configured sources of a module.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleConsolidatedArtifact(
|
||||
string MigrationName,
|
||||
string Script,
|
||||
string Checksum,
|
||||
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> SourceMigrations);
|
||||
|
||||
/// <summary>
|
||||
/// Source migration metadata retained for compatibility backfill.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleConsolidatedSourceMigration(
|
||||
string Name,
|
||||
MigrationCategory Category,
|
||||
string Checksum,
|
||||
string Content,
|
||||
string SourceResourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Builds deterministic consolidated migration scripts per service module.
|
||||
/// </summary>
|
||||
public static class MigrationModuleConsolidation
|
||||
{
|
||||
public static MigrationModuleConsolidatedArtifact Build(MigrationModuleInfo module)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(module);
|
||||
|
||||
var migrationsByName = new Dictionary<string, MigrationModuleConsolidatedSourceMigration>(StringComparer.Ordinal);
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
foreach (var migration in LoadMigrationsFromSource(source))
|
||||
{
|
||||
if (migrationsByName.TryGetValue(migration.Name, out var existing))
|
||||
{
|
||||
if (!string.Equals(existing.Checksum, migration.Checksum, StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Duplicate migration name '{migration.Name}' with different content discovered while consolidating module '{module.Name}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
migrationsByName[migration.Name] = migration;
|
||||
}
|
||||
}
|
||||
|
||||
if (migrationsByName.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Module '{module.Name}' has no migration resources to consolidate.");
|
||||
}
|
||||
|
||||
var sourceMigrations = migrationsByName.Values
|
||||
.OrderBy(static migration => migration.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var script = BuildConsolidatedScript(module, sourceMigrations);
|
||||
var checksum = ComputeChecksum(script);
|
||||
var migrationName = $"100_consolidated_{NormalizeModuleName(module.Name)}.sql";
|
||||
|
||||
return new MigrationModuleConsolidatedArtifact(
|
||||
migrationName,
|
||||
script,
|
||||
checksum,
|
||||
sourceMigrations);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MigrationModuleConsolidatedSourceMigration> LoadMigrationsFromSource(
|
||||
MigrationModuleSourceInfo source)
|
||||
{
|
||||
var resources = source.MigrationsAssembly
|
||||
.GetManifestResourceNames()
|
||||
.Where(static name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
||||
.Where(name =>
|
||||
string.IsNullOrWhiteSpace(source.ResourcePrefix) ||
|
||||
name.Contains(source.ResourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static name => name, StringComparer.Ordinal);
|
||||
|
||||
var migrations = new List<MigrationModuleConsolidatedSourceMigration>();
|
||||
foreach (var resourceName in resources)
|
||||
{
|
||||
using var stream = source.MigrationsAssembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var content = reader.ReadToEnd();
|
||||
var fileName = ExtractFileName(resourceName);
|
||||
var category = MigrationCategoryExtensions.GetCategory(fileName);
|
||||
var checksum = ComputeChecksum(content);
|
||||
|
||||
migrations.Add(new MigrationModuleConsolidatedSourceMigration(
|
||||
Name: fileName,
|
||||
Category: category,
|
||||
Checksum: checksum,
|
||||
Content: content,
|
||||
SourceResourceName: resourceName));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private static string BuildConsolidatedScript(
|
||||
MigrationModuleInfo module,
|
||||
IReadOnlyList<MigrationModuleConsolidatedSourceMigration> sourceMigrations)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("-- Consolidated migration for module '");
|
||||
builder.Append(module.Name);
|
||||
builder.AppendLine("'.");
|
||||
builder.Append("-- Generated deterministically from ");
|
||||
builder.Append(sourceMigrations.Count);
|
||||
builder.AppendLine(" source migrations.");
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var migration in sourceMigrations)
|
||||
{
|
||||
builder.Append("-- BEGIN ");
|
||||
builder.AppendLine(migration.SourceResourceName);
|
||||
builder.AppendLine(migration.Content.TrimEnd());
|
||||
builder.Append("-- END ");
|
||||
builder.AppendLine(migration.SourceResourceName);
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractFileName(string resourceName)
|
||||
{
|
||||
var lastSlash = resourceName.LastIndexOf('/');
|
||||
if (lastSlash >= 0)
|
||||
{
|
||||
return resourceName[(lastSlash + 1)..];
|
||||
}
|
||||
|
||||
var parts = resourceName.Split('.');
|
||||
for (var i = parts.Length - 1; i >= 0; i--)
|
||||
{
|
||||
if (parts[i].EndsWith("sql", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i > 0 ? $"{parts[i - 1]}.sql" : parts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return resourceName;
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
var normalized = content.Replace("\r\n", "\n", StringComparison.Ordinal)
|
||||
.Replace("\r", "\n", StringComparison.Ordinal);
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static string NormalizeModuleName(string moduleName)
|
||||
{
|
||||
var chars = moduleName.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.ToArray();
|
||||
return chars.Length == 0 ? "module" : new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,23 @@ internal static class MigrationModulePluginDiscovery
|
||||
$"Invalid migration module plugin '{plugin.GetType().FullName}': schema name is required.");
|
||||
}
|
||||
|
||||
if (module.Sources.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid migration module plugin '{plugin.GetType().FullName}': at least one migration source is required.");
|
||||
}
|
||||
|
||||
var sourceSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var source in module.Sources)
|
||||
{
|
||||
var sourceIdentity = $"{source.MigrationsAssembly.FullName}|{source.ResourcePrefix}";
|
||||
if (!sourceSet.Add(sourceIdentity))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Invalid migration module plugin '{plugin.GetType().FullName}': duplicate migration source '{sourceIdentity}' for module '{module.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!modulesByName.TryAdd(module.Name, module))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
@@ -161,4 +178,3 @@ internal static class MigrationModulePluginDiscovery
|
||||
return directories.OrderBy(static directory => directory, StringComparer.Ordinal).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,100 +1,296 @@
|
||||
using StellaOps.AdvisoryAI.Storage.Postgres;
|
||||
using StellaOps.Attestor.Persistence;
|
||||
using StellaOps.Eventing.Postgres;
|
||||
using StellaOps.AirGap.Persistence.Postgres;
|
||||
using StellaOps.BinaryIndex.GoldenSet;
|
||||
using StellaOps.BinaryIndex.Persistence;
|
||||
using StellaOps.EvidenceLocker.Infrastructure.Db;
|
||||
using StellaOps.Artifact.Infrastructure;
|
||||
using StellaOps.Authority.Persistence.Postgres;
|
||||
using StellaOps.Concelier.Persistence.Postgres;
|
||||
using StellaOps.Evidence.Persistence.Postgres;
|
||||
using StellaOps.Excititor.Persistence.Postgres;
|
||||
using StellaOps.Notify.Persistence.Postgres;
|
||||
using StellaOps.Plugin.Registry;
|
||||
using StellaOps.Policy.Persistence.Postgres;
|
||||
using StellaOps.ReachGraph.Persistence.Postgres;
|
||||
using StellaOps.Remediation.Persistence.Postgres;
|
||||
using StellaOps.SbomService.Lineage.Persistence;
|
||||
using StellaOps.Scanner.Storage.Postgres;
|
||||
using StellaOps.Scanner.Triage;
|
||||
using StellaOps.Scheduler.Persistence.Postgres;
|
||||
using StellaOps.Timeline.Core.Postgres;
|
||||
using StellaOps.TimelineIndexer.Infrastructure;
|
||||
using StellaOps.Verdict.Persistence.Postgres;
|
||||
using StellaOps.Signals.Persistence.Postgres;
|
||||
using StellaOps.Graph.Indexer.Persistence.Postgres;
|
||||
using StellaOps.Unknowns.Persistence.Postgres;
|
||||
using StellaOps.VexHub.Persistence.Postgres;
|
||||
using StellaOps.VexLens.Persistence.Postgres;
|
||||
using StellaOps.Findings.Ledger.Infrastructure.Postgres;
|
||||
using StellaOps.Orchestrator.Infrastructure.Postgres;
|
||||
|
||||
namespace StellaOps.Platform.Database;
|
||||
|
||||
public sealed class AdvisoryAiMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "AdvisoryAI",
|
||||
schemaName: "advisoryai",
|
||||
migrationsAssembly: typeof(AdvisoryAiDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class AirGapMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
Name: "AirGap",
|
||||
SchemaName: "airgap",
|
||||
MigrationsAssembly: typeof(AirGapDataSource).Assembly);
|
||||
name: "AirGap",
|
||||
schemaName: "airgap",
|
||||
migrationsAssembly: typeof(AirGapDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class AttestorMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Attestor",
|
||||
schemaName: "proofchain",
|
||||
migrationsAssembly: typeof(ProofChainDbContext).Assembly,
|
||||
resourcePrefix: "StellaOps.Attestor.Persistence.Migrations");
|
||||
}
|
||||
|
||||
public sealed class BinaryIndexMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "BinaryIndex",
|
||||
schemaName: "binaries",
|
||||
sources:
|
||||
[
|
||||
new MigrationModuleSourceInfo(
|
||||
typeof(BinaryIndexMigrationRunner).Assembly,
|
||||
"StellaOps.BinaryIndex.Persistence.Migrations"),
|
||||
new MigrationModuleSourceInfo(
|
||||
typeof(PostgresGoldenSetStore).Assembly,
|
||||
"StellaOps.BinaryIndex.GoldenSet.Migrations")
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class AuthorityMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
Name: "Authority",
|
||||
SchemaName: "authority",
|
||||
MigrationsAssembly: typeof(AuthorityDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Authority.Persistence.Migrations");
|
||||
name: "Authority",
|
||||
schemaName: "authority",
|
||||
migrationsAssembly: typeof(AuthorityDataSource).Assembly,
|
||||
resourcePrefix: "StellaOps.Authority.Persistence.Migrations");
|
||||
}
|
||||
|
||||
public sealed class EventingMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Eventing",
|
||||
schemaName: "timeline",
|
||||
migrationsAssembly: typeof(EventingDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class GraphMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Graph",
|
||||
schemaName: "graph",
|
||||
sources:
|
||||
[
|
||||
new MigrationModuleSourceInfo(typeof(GraphIndexerDataSource).Assembly)
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class EvidenceMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Evidence",
|
||||
schemaName: "evidence",
|
||||
sources:
|
||||
[
|
||||
new MigrationModuleSourceInfo(typeof(EvidenceDataSource).Assembly),
|
||||
new MigrationModuleSourceInfo(typeof(ArtifactDataSource).Assembly)
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class EvidenceLockerMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "EvidenceLocker",
|
||||
schemaName: "evidence_locker",
|
||||
migrationsAssembly: typeof(EvidenceLockerDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class SchedulerMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
Name: "Scheduler",
|
||||
SchemaName: "scheduler",
|
||||
MigrationsAssembly: typeof(SchedulerDataSource).Assembly,
|
||||
ResourcePrefix: "StellaOps.Scheduler.Persistence.Migrations");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
name: "Excititor",
|
||||
schemaName: "vex",
|
||||
migrationsAssembly: typeof(ExcititorDataSource).Assembly,
|
||||
resourcePrefix: "StellaOps.Excititor.Persistence.Migrations");
|
||||
}
|
||||
|
||||
public sealed class PluginRegistryMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "PluginRegistry",
|
||||
schemaName: "platform",
|
||||
migrationsAssembly: typeof(PluginRegistryMigrationRunner).Assembly,
|
||||
resourcePrefix: "StellaOps.Plugin.Registry.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");
|
||||
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);
|
||||
name: "Scanner",
|
||||
schemaName: "scanner",
|
||||
sources:
|
||||
[
|
||||
new MigrationModuleSourceInfo(typeof(ScannerDataSource).Assembly),
|
||||
new MigrationModuleSourceInfo(
|
||||
typeof(TriageDbContext).Assembly,
|
||||
"StellaOps.Scanner.Triage.Migrations")
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class SignalsMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Signals",
|
||||
schemaName: "signals",
|
||||
migrationsAssembly: typeof(SignalsDataSource).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");
|
||||
name: "TimelineIndexer",
|
||||
schemaName: "timeline",
|
||||
sources:
|
||||
[
|
||||
new MigrationModuleSourceInfo(
|
||||
typeof(TimelineIndexerDataSource).Assembly,
|
||||
"StellaOps.TimelineIndexer.Infrastructure.Db.Migrations"),
|
||||
new MigrationModuleSourceInfo(
|
||||
typeof(TimelineCoreDataSource).Assembly)
|
||||
]);
|
||||
}
|
||||
|
||||
public sealed class VexHubMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "VexHub",
|
||||
schemaName: "vexhub",
|
||||
migrationsAssembly: typeof(VexHubDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class RemediationMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Remediation",
|
||||
schemaName: "remediation",
|
||||
migrationsAssembly: typeof(RemediationDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class VexLensMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "VexLens",
|
||||
schemaName: "vexlens",
|
||||
migrationsAssembly: typeof(VexLensDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class SbomLineageMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "SbomLineage",
|
||||
schemaName: "sbom",
|
||||
migrationsAssembly: typeof(LineageDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class ReachGraphMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "ReachGraph",
|
||||
schemaName: "reachgraph",
|
||||
migrationsAssembly: typeof(ReachGraphDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class UnknownsMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Unknowns",
|
||||
schemaName: "unknowns",
|
||||
migrationsAssembly: typeof(UnknownsDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class VerdictMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Verdict",
|
||||
schemaName: "stellaops",
|
||||
migrationsAssembly: typeof(VerdictDataSource).Assembly,
|
||||
resourcePrefix: "StellaOps.Verdict.Persistence.Migrations");
|
||||
}
|
||||
|
||||
public sealed class OrchestratorMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "Orchestrator",
|
||||
schemaName: "orchestrator",
|
||||
migrationsAssembly: typeof(OrchestratorDataSource).Assembly);
|
||||
}
|
||||
|
||||
public sealed class FindingsLedgerMigrationModulePlugin : IMigrationModulePlugin
|
||||
{
|
||||
public MigrationModuleInfo Module { get; } = new(
|
||||
name: "FindingsLedger",
|
||||
schemaName: "public",
|
||||
migrationsAssembly: typeof(LedgerDataSource).Assembly,
|
||||
resourcePrefix: "StellaOps.Findings.Ledger.migrations");
|
||||
}
|
||||
|
||||
@@ -4,14 +4,62 @@ using System.Threading;
|
||||
namespace StellaOps.Platform.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a PostgreSQL module with migration metadata.
|
||||
/// Defines one migration source (assembly + optional resource prefix) for a service module.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleInfo(
|
||||
string Name,
|
||||
string SchemaName,
|
||||
public sealed record MigrationModuleSourceInfo(
|
||||
Assembly MigrationsAssembly,
|
||||
string? ResourcePrefix = null);
|
||||
|
||||
/// <summary>
|
||||
/// Defines a PostgreSQL module with migration metadata.
|
||||
/// </summary>
|
||||
public sealed record MigrationModuleInfo
|
||||
{
|
||||
public MigrationModuleInfo(
|
||||
string name,
|
||||
string schemaName,
|
||||
Assembly migrationsAssembly,
|
||||
string? resourcePrefix = null)
|
||||
: this(
|
||||
name,
|
||||
schemaName,
|
||||
[new MigrationModuleSourceInfo(migrationsAssembly, resourcePrefix)])
|
||||
{
|
||||
}
|
||||
|
||||
public MigrationModuleInfo(
|
||||
string name,
|
||||
string schemaName,
|
||||
IReadOnlyList<MigrationModuleSourceInfo> sources)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(schemaName);
|
||||
ArgumentNullException.ThrowIfNull(sources);
|
||||
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one migration source is required.", nameof(sources));
|
||||
}
|
||||
|
||||
if (sources.Any(static source => source.MigrationsAssembly is null))
|
||||
{
|
||||
throw new ArgumentException("Migration source assembly cannot be null.", nameof(sources));
|
||||
}
|
||||
|
||||
Name = name;
|
||||
SchemaName = schemaName;
|
||||
Sources = sources.ToArray();
|
||||
MigrationsAssembly = Sources[0].MigrationsAssembly;
|
||||
ResourcePrefix = Sources[0].ResourcePrefix;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public string SchemaName { get; }
|
||||
public Assembly MigrationsAssembly { get; }
|
||||
public string? ResourcePrefix { get; }
|
||||
public IReadOnlyList<MigrationModuleSourceInfo> Sources { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical PostgreSQL migration module registry owned by Platform.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Release schema prerequisite for tenant fallback lookups.
|
||||
-- Keeps clean-install migration execution independent from optional shared-schema owners.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS shared;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shared.tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_shared_tenants_single_default
|
||||
ON shared.tenants (is_default)
|
||||
WHERE is_default;
|
||||
@@ -151,10 +151,11 @@ CREATE TABLE IF NOT EXISTS release.release_tags (
|
||||
release_id UUID NOT NULL REFERENCES release.releases(id) ON DELETE CASCADE,
|
||||
environment_id UUID REFERENCES release.environments(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID NOT NULL,
|
||||
PRIMARY KEY (tenant_id, tag, COALESCE(environment_id, '00000000-0000-0000-0000-000000000000'::UUID))
|
||||
created_by UUID NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_release_tags_tenant_tag_environment
|
||||
ON release.release_tags (tenant_id, tag, COALESCE(environment_id, '00000000-0000-0000-0000-000000000000'::UUID));
|
||||
CREATE INDEX idx_release_tags_release ON release.release_tags(release_id);
|
||||
CREATE INDEX idx_release_tags_environment ON release.release_tags(environment_id)
|
||||
WHERE environment_id IS NOT NULL;
|
||||
|
||||
@@ -85,13 +85,14 @@ COMMENT ON COLUMN release.agent_capabilities.version IS 'Version of the capabili
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.agent_heartbeats (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
agent_id UUID NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
status JSONB NOT NULL,
|
||||
latency_ms INT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (id, created_at)
|
||||
-- No FK to agents for partition performance
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
|
||||
@@ -87,10 +87,11 @@ CREATE TABLE IF NOT EXISTS release.plugins (
|
||||
entry_point TEXT,
|
||||
config_defaults JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_plugins_scope_name
|
||||
ON release.plugins (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name);
|
||||
CREATE INDEX idx_plugins_tenant ON release.plugins(tenant_id);
|
||||
CREATE INDEX idx_plugins_type ON release.plugins(plugin_type_id);
|
||||
CREATE INDEX idx_plugins_enabled ON release.plugins(tenant_id, is_enabled)
|
||||
@@ -154,10 +155,11 @@ CREATE TABLE IF NOT EXISTS release.plugin_instances (
|
||||
invocation_count BIGINT NOT NULL DEFAULT 0,
|
||||
error_count BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, plugin_id, COALESCE(instance_name, ''))
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_plugin_instances_scope_name
|
||||
ON release.plugin_instances (tenant_id, plugin_id, COALESCE(instance_name, ''));
|
||||
CREATE INDEX idx_plugin_instances_tenant ON release.plugin_instances(tenant_id);
|
||||
CREATE INDEX idx_plugin_instances_plugin ON release.plugin_instances(plugin_id);
|
||||
CREATE INDEX idx_plugin_instances_enabled ON release.plugin_instances(tenant_id, is_enabled)
|
||||
|
||||
@@ -28,11 +28,11 @@ CREATE TABLE IF NOT EXISTS release.policy_profiles (
|
||||
on_fail_hard TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_by UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- Ensure unique names within tenant scope (NULL tenant = instance level)
|
||||
UNIQUE (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name)
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_policy_profiles_scope_name
|
||||
ON release.policy_profiles (COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::UUID), name);
|
||||
CREATE INDEX idx_policy_profiles_tenant ON release.policy_profiles(tenant_id);
|
||||
CREATE INDEX idx_policy_profiles_type ON release.policy_profiles(profile_type);
|
||||
CREATE INDEX idx_policy_profiles_default ON release.policy_profiles(tenant_id)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.Database.EfCore.CompiledModels;
|
||||
using StellaOps.Platform.Database.EfCore.Context;
|
||||
|
||||
namespace StellaOps.Platform.Database.Postgres;
|
||||
|
||||
public static class PlatformDbContextFactory
|
||||
{
|
||||
public const string DefaultSchemaName = "platform";
|
||||
|
||||
public static PlatformDbContext Create(NpgsqlConnection connection, int commandTimeoutSeconds, string schemaName)
|
||||
{
|
||||
var normalizedSchema = string.IsNullOrWhiteSpace(schemaName)
|
||||
? DefaultSchemaName
|
||||
: schemaName.Trim();
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<PlatformDbContext>()
|
||||
.UseNpgsql(connection, npgsql => npgsql.CommandTimeout(commandTimeoutSeconds));
|
||||
|
||||
if (string.Equals(normalizedSchema, DefaultSchemaName, StringComparison.Ordinal))
|
||||
{
|
||||
// Use the static compiled model when schema mapping matches the default model.
|
||||
optionsBuilder.UseModel(PlatformDbContextModel.Instance);
|
||||
}
|
||||
|
||||
return new PlatformDbContext(optionsBuilder.Options, normalizedSchema);
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,38 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\AdvisoryAI\StellaOps.AdvisoryAI\StellaOps.AdvisoryAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\AirGap\__Libraries\StellaOps.AirGap.Persistence\StellaOps.AirGap.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\Attestor\__Libraries\StellaOps.Attestor.Persistence\StellaOps.Attestor.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.Persistence\StellaOps.BinaryIndex.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\BinaryIndex\__Libraries\StellaOps.BinaryIndex.GoldenSet\StellaOps.BinaryIndex.GoldenSet.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Artifact.Infrastructure\StellaOps.Artifact.Infrastructure.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="..\..\..\Graph\__Libraries\StellaOps.Graph.Indexer.Persistence\StellaOps.Graph.Indexer.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Evidence.Persistence\StellaOps.Evidence.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="..\..\..\Plugin\StellaOps.Plugin.Registry\StellaOps.Plugin.Registry.csproj" />
|
||||
<ProjectReference Include="..\..\..\Policy\__Libraries\StellaOps.Policy.Persistence\StellaOps.Policy.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\SbomService\__Libraries\StellaOps.SbomService.Lineage\StellaOps.SbomService.Lineage.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Storage\StellaOps.Scanner.Storage.csproj" />
|
||||
<ProjectReference Include="..\..\..\Signals\__Libraries\StellaOps.Signals.Persistence\StellaOps.Signals.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scanner\__Libraries\StellaOps.Scanner.Triage\StellaOps.Scanner.Triage.csproj" />
|
||||
<ProjectReference Include="..\..\..\Scheduler\__Libraries\StellaOps.Scheduler.Persistence\StellaOps.Scheduler.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\Timeline\__Libraries\StellaOps.Timeline.Core\StellaOps.Timeline.Core.csproj" />
|
||||
<ProjectReference Include="..\..\..\TimelineIndexer\StellaOps.TimelineIndexer\StellaOps.TimelineIndexer.Infrastructure\StellaOps.TimelineIndexer.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\Unknowns\__Libraries\StellaOps.Unknowns.Persistence\StellaOps.Unknowns.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\VexHub\__Libraries\StellaOps.VexHub.Persistence\StellaOps.VexHub.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\VexLens\StellaOps.VexLens.Persistence\StellaOps.VexLens.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\Remediation\StellaOps.Remediation.Persistence\StellaOps.Remediation.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.ReachGraph.Persistence\StellaOps.ReachGraph.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Verdict\StellaOps.Verdict.csproj" />
|
||||
<ProjectReference Include="..\..\..\EvidenceLocker\StellaOps.EvidenceLocker\StellaOps.EvidenceLocker.Infrastructure\StellaOps.EvidenceLocker.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Eventing\StellaOps.Eventing.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Infrastructure.Postgres\StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="..\..\..\Findings\StellaOps.Findings.Ledger\StellaOps.Findings.Ledger.csproj" />
|
||||
<ProjectReference Include="..\..\..\Orchestrator\StellaOps.Orchestrator\StellaOps.Orchestrator.Infrastructure\StellaOps.Orchestrator.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,6 +50,17 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Prevent automatic compiled-model binding so non-default schemas can build runtime models. -->
|
||||
<Compile Remove="EfCore\CompiledModels\PlatformDbContextAssemblyAttributes.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Migrations\**\*.sql" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -6,6 +6,7 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| --- | --- | --- |
|
||||
| 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. |
|
||||
| SPRINT_20260222_051-MGC-04-W1-SOURCES | DONE | Extended service plugin model to support source-set flattening (multiple migration sources per web service), including Scanner storage+triage source registration under one `ScannerMigrationModulePlugin`, plus synthesized per-plugin consolidated migration artifact generation for empty-history execution and partial-history backfill self-healing. |
|
||||
| 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. |
|
||||
@@ -13,3 +14,8 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
| B22-05-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `051_IntegrationSourceHealth.sql` for integrations feed and VEX source health/freshness read-model projection objects. |
|
||||
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Platform/__Libraries/StellaOps.Platform.Database/StellaOps.Platform.Database.md. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| PLATFORM-EF-01 | DONE | Sprint `docs/implplan/SPRINT_20260222_096_Platform_dal_to_efcore.md`: verified AGENTS.md alignment and `PlatformMigrationModulePlugin` registration in migration registry. |
|
||||
| PLATFORM-EF-02 | DONE | Sprint 096: scaffolded EF Core model baseline under `EfCore/Context/`, `EfCore/Models/`, `EfCore/CompiledModels/` for platform schema tables (`environment_settings`, `context_regions`, `context_environments`, `ui_context_preferences`). |
|
||||
| PLATFORM-EF-03 | DONE | Sprint 096: converted `PostgresEnvironmentSettingsStore` and `PostgresPlatformContextStore` reads to EF Core LINQ with `AsNoTracking()`; PostgreSQL-specific upserts retained as raw SQL. `PostgresScoreHistoryStore` retained as raw Npgsql (cross-module signals schema). |
|
||||
| PLATFORM-EF-04 | DONE | Sprint 096: added design-time factory (`PlatformDesignTimeDbContextFactory`), runtime factory (`PlatformDbContextFactory`) with `UseModel(PlatformDbContextModel.Instance)` for default schema, compiled model stubs with `// <auto-generated />` header, assembly attribute exclusion in csproj. |
|
||||
| PLATFORM-EF-05 | DONE | Sprint 096: sequential builds pass for Platform.Database (0W/0E), Platform.WebService (0W/0E), Platform.WebService.Tests (0W/0E). TASKS.md and sprint tracker updated. |
|
||||
|
||||
@@ -93,6 +93,64 @@ public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplication
|
||||
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Preferences_AreIsolatedPerTenantForSameActor()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "shared-actor");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
|
||||
|
||||
var tenantARequest = new PlatformContextPreferencesRequest(
|
||||
Regions: new[] { "us-east" },
|
||||
Environments: new[] { "us-prod" },
|
||||
TimeWindow: "7d");
|
||||
|
||||
var tenantAResponse = await client.PutAsJsonAsync(
|
||||
"/api/v2/context/preferences",
|
||||
tenantARequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
tenantAResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var tenantAPreferences = await tenantAResponse.Content.ReadFromJsonAsync<PlatformContextPreferences>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(tenantAPreferences);
|
||||
|
||||
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-b");
|
||||
|
||||
var tenantBDefaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(tenantBDefaults);
|
||||
Assert.Equal("tenant-context-b", tenantBDefaults!.TenantId);
|
||||
Assert.NotEqual("7d", tenantBDefaults.TimeWindow);
|
||||
Assert.NotEqual(tenantAPreferences!.Regions.ToArray(), tenantBDefaults.Regions.ToArray());
|
||||
|
||||
var tenantBRequest = new PlatformContextPreferencesRequest(
|
||||
Regions: new[] { "eu-west" },
|
||||
Environments: new[] { "eu-prod" },
|
||||
TimeWindow: "30d");
|
||||
|
||||
var tenantBUpdate = await client.PutAsJsonAsync(
|
||||
"/api/v2/context/preferences",
|
||||
tenantBRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
tenantBUpdate.EnsureSuccessStatusCode();
|
||||
|
||||
client.DefaultRequestHeaders.Remove("X-StellaOps-Tenant");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-context-a");
|
||||
|
||||
var tenantAReloaded = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(tenantAReloaded);
|
||||
Assert.Equal("tenant-context-a", tenantAReloaded!.TenantId);
|
||||
Assert.Equal(new[] { "us-east" }, tenantAReloaded.Regions.ToArray());
|
||||
Assert.Equal(new[] { "us-prod" }, tenantAReloaded.Environments.ToArray());
|
||||
Assert.Equal("7d", tenantAReloaded.TimeWindow);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
|
||||
@@ -13,16 +13,34 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
|
||||
{
|
||||
private static readonly string[] ExpectedModules =
|
||||
[
|
||||
"AdvisoryAI",
|
||||
"AirGap",
|
||||
"Attestor",
|
||||
"Authority",
|
||||
"BinaryIndex",
|
||||
"Concelier",
|
||||
"Eventing",
|
||||
"Evidence",
|
||||
"EvidenceLocker",
|
||||
"Excititor",
|
||||
"FindingsLedger",
|
||||
"Graph",
|
||||
"Notify",
|
||||
"Orchestrator",
|
||||
"Platform",
|
||||
"PluginRegistry",
|
||||
"Policy",
|
||||
"ReachGraph",
|
||||
"Remediation",
|
||||
"SbomLineage",
|
||||
"Scanner",
|
||||
"Scheduler",
|
||||
"TimelineIndexer"
|
||||
"Signals",
|
||||
"TimelineIndexer",
|
||||
"Unknowns",
|
||||
"Verdict",
|
||||
"VexHub",
|
||||
"VexLens"
|
||||
];
|
||||
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
@@ -139,4 +157,18 @@ public sealed class MigrationAdminEndpointsTests : IClassFixture<PlatformWebAppl
|
||||
Assert.NotNull(problem);
|
||||
Assert.Equal("Release migration approval required", problem!.Title);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Modules_WithoutTenantHeader_RemainsAccessibleAsSystemEndpoint()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "migration-admin-system-test");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/admin/migrations/modules",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using Xunit;
|
||||
@@ -37,4 +38,19 @@ public sealed class OnboardingEndpointsTests : IClassFixture<PlatformWebApplicat
|
||||
state.Steps.OrderBy(item => item.Step, System.StringComparer.Ordinal).Select(item => item.Step),
|
||||
state.Steps.Select(item => item.Step));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Onboarding_SetupStatusRoute_RejectsCrossTenantAccess()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-onboarding-a");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-onboarding");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/platform/tenants/tenant-onboarding-b/setup-status",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PlatformRequestContextResolverTests
|
||||
{
|
||||
private readonly PlatformRequestContextResolver _resolver = new();
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_WithCanonicalClaim_UsesClaimTenant()
|
||||
{
|
||||
var context = CreateContext(
|
||||
claims:
|
||||
[
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "Tenant-A"),
|
||||
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
|
||||
],
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
|
||||
});
|
||||
|
||||
var success = _resolver.TryResolve(context, out var requestContext, out var error);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(requestContext);
|
||||
Assert.Equal("tenant-a", requestContext!.TenantId);
|
||||
Assert.Equal("subject-a", requestContext.ActorId);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_WithConflictingClaimAndHeader_ReturnsTenantConflict()
|
||||
{
|
||||
var context = CreateContext(
|
||||
claims:
|
||||
[
|
||||
new Claim(StellaOpsClaimTypes.Tenant, "tenant-a"),
|
||||
new Claim(StellaOpsClaimTypes.Subject, "subject-a"),
|
||||
],
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
[StellaOpsHttpHeaderNames.Tenant] = "tenant-b",
|
||||
});
|
||||
|
||||
var success = _resolver.TryResolve(context, out var requestContext, out var error);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(requestContext);
|
||||
Assert.Equal("tenant_conflict", error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_WithLegacyTidClaim_UsesLegacyClaimFallback()
|
||||
{
|
||||
var context = CreateContext(
|
||||
claims:
|
||||
[
|
||||
new Claim("tid", "legacy-tenant"),
|
||||
new Claim(StellaOpsClaimTypes.ClientId, "client-a"),
|
||||
]);
|
||||
|
||||
var success = _resolver.TryResolve(context, out var requestContext, out var error);
|
||||
|
||||
Assert.True(success);
|
||||
Assert.NotNull(requestContext);
|
||||
Assert.Equal("legacy-tenant", requestContext!.TenantId);
|
||||
Assert.Equal("client-a", requestContext.ActorId);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_WithConflictingCanonicalAndLegacyHeaders_ReturnsTenantConflict()
|
||||
{
|
||||
var context = CreateContext(
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
[StellaOpsHttpHeaderNames.Tenant] = "tenant-a",
|
||||
["X-Stella-Tenant"] = "tenant-b",
|
||||
["X-StellaOps-Actor"] = "actor-a",
|
||||
});
|
||||
|
||||
var success = _resolver.TryResolve(context, out var requestContext, out var error);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(requestContext);
|
||||
Assert.Equal("tenant_conflict", error);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TryResolve_WithoutTenant_ReturnsTenantMissing()
|
||||
{
|
||||
var context = CreateContext(
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-StellaOps-Actor"] = "actor-a",
|
||||
});
|
||||
|
||||
var success = _resolver.TryResolve(context, out var requestContext, out var error);
|
||||
|
||||
Assert.False(success);
|
||||
Assert.Null(requestContext);
|
||||
Assert.Equal("tenant_missing", error);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateContext(
|
||||
IReadOnlyList<Claim>? claims = null,
|
||||
IReadOnlyDictionary<string, string>? headers = null)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
if (claims is not null)
|
||||
{
|
||||
context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
||||
}
|
||||
|
||||
if (headers is not null)
|
||||
{
|
||||
foreach (var pair in headers)
|
||||
{
|
||||
context.Request.Headers[pair.Key] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -105,4 +105,23 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("quotaId is required.", body!["error"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task Quotas_TenantRoute_RejectsCrossTenantAccess()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-quotas-a");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-quotas");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v1/platform/quotas/tenants/tenant-quotas-b",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Forbidden, response.StatusCode);
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonObject>(TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,38 @@ public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebA
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task TopologyEndpoints_IsolateDataAcrossTenantsWithOverlappingReleaseSlugs()
|
||||
{
|
||||
using var tenantAClient = CreateTenantClient("tenant-topology-a");
|
||||
using var tenantBClient = CreateTenantClient("tenant-topology-b");
|
||||
|
||||
await SeedReleaseAsync(tenantAClient, "shared-release", "Shared Release", "us-prod", "promotion");
|
||||
await SeedReleaseAsync(tenantBClient, "shared-release", "Shared Release", "us-prod", "promotion");
|
||||
|
||||
var tenantATargets = await tenantAClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var tenantBTargets = await tenantBClient.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(tenantATargets);
|
||||
Assert.NotNull(tenantBTargets);
|
||||
Assert.Equal("tenant-topology-a", tenantATargets!.TenantId);
|
||||
Assert.Equal("tenant-topology-b", tenantBTargets!.TenantId);
|
||||
Assert.Equal(2, tenantATargets.Items.Count);
|
||||
Assert.Equal(2, tenantBTargets.Items.Count);
|
||||
|
||||
var tenantBTargetIds = tenantBTargets.Items
|
||||
.Select(item => item.TargetId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
Assert.DoesNotContain(
|
||||
tenantATargets.Items.Select(item => item.TargetId),
|
||||
targetId => tenantBTargetIds.Contains(targetId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TopologyEndpoints_RequireExpectedPolicy()
|
||||
|
||||
Reference in New Issue
Block a user