ui progressing
This commit is contained in:
@@ -10,6 +10,12 @@ public static class PlatformPolicies
|
||||
public const string OnboardingWrite = "platform.onboarding.write";
|
||||
public const string PreferencesRead = "platform.preferences.read";
|
||||
public const string PreferencesWrite = "platform.preferences.write";
|
||||
public const string ContextRead = "platform.context.read";
|
||||
public const string ContextWrite = "platform.context.write";
|
||||
public const string TopologyRead = "platform.topology.read";
|
||||
public const string SecurityRead = "platform.security.read";
|
||||
public const string IntegrationsRead = "platform.integrations.read";
|
||||
public const string IntegrationsVexRead = "platform.integrations.vex.read";
|
||||
public const string SearchRead = "platform.search.read";
|
||||
public const string MetadataRead = "platform.metadata.read";
|
||||
public const string AnalyticsRead = "platform.analytics.read";
|
||||
|
||||
@@ -12,6 +12,11 @@ public static class PlatformScopes
|
||||
public const string OnboardingWrite = "onboarding.write";
|
||||
public const string PreferencesRead = "ui.preferences.read";
|
||||
public const string PreferencesWrite = "ui.preferences.write";
|
||||
public const string ContextRead = "platform.context.read";
|
||||
public const string ContextWrite = "platform.context.write";
|
||||
public const string FindingsRead = "findings:read";
|
||||
public const string AdvisoryRead = StellaOpsScopes.AdvisoryRead;
|
||||
public const string VexRead = StellaOpsScopes.VexRead;
|
||||
public const string SearchRead = "search.read";
|
||||
public const string MetadataRead = "platform.metadata.read";
|
||||
public const string AnalyticsRead = "analytics.read";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record PlatformContextRegion(
|
||||
string RegionId,
|
||||
string DisplayName,
|
||||
int SortOrder,
|
||||
bool Enabled = true);
|
||||
|
||||
public sealed record PlatformContextEnvironment(
|
||||
string EnvironmentId,
|
||||
string RegionId,
|
||||
string EnvironmentType,
|
||||
string DisplayName,
|
||||
int SortOrder,
|
||||
bool Enabled = true);
|
||||
|
||||
public sealed record PlatformContextPreferences(
|
||||
string TenantId,
|
||||
string ActorId,
|
||||
IReadOnlyList<string> Regions,
|
||||
IReadOnlyList<string> Environments,
|
||||
string TimeWindow,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string UpdatedBy);
|
||||
|
||||
public sealed record PlatformContextPreferencesRequest(
|
||||
IReadOnlyList<string>? Regions,
|
||||
IReadOnlyList<string>? Environments,
|
||||
string? TimeWindow);
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record IntegrationFeedProjection(
|
||||
string SourceId,
|
||||
string SourceName,
|
||||
string SourceType,
|
||||
string Provider,
|
||||
string Region,
|
||||
string Environment,
|
||||
string Status,
|
||||
string Freshness,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
int? FreshnessMinutes,
|
||||
int SlaMinutes,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
string? LastError,
|
||||
IReadOnlyList<string> ConsumerDomains);
|
||||
|
||||
public sealed record IntegrationVexSourceProjection(
|
||||
string SourceId,
|
||||
string SourceName,
|
||||
string SourceType,
|
||||
string Provider,
|
||||
string Region,
|
||||
string Environment,
|
||||
string Status,
|
||||
string Freshness,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
int? FreshnessMinutes,
|
||||
int SlaMinutes,
|
||||
string StatementFormat,
|
||||
int DocumentCount24h,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
string? LastError,
|
||||
IReadOnlyList<string> ConsumerDomains);
|
||||
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record ReleaseGateSummary(
|
||||
string Status,
|
||||
int BlockingCount,
|
||||
int PendingApprovals,
|
||||
IReadOnlyList<string> BlockingReasons);
|
||||
|
||||
public sealed record ReleaseRiskSummary(
|
||||
int CriticalReachable,
|
||||
int HighReachable,
|
||||
string Trend);
|
||||
|
||||
public sealed record ReleaseProjection(
|
||||
string ReleaseId,
|
||||
string Slug,
|
||||
string Name,
|
||||
string ReleaseType,
|
||||
string Status,
|
||||
string? TargetEnvironment,
|
||||
string? TargetRegion,
|
||||
int TotalVersions,
|
||||
int? LatestVersionNumber,
|
||||
string? LatestVersionDigest,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
DateTimeOffset? LatestPublishedAt,
|
||||
ReleaseGateSummary Gate,
|
||||
ReleaseRiskSummary Risk);
|
||||
|
||||
public sealed record ReleaseDetailProjection(
|
||||
ReleaseProjection Summary,
|
||||
IReadOnlyList<ReleaseControlBundleVersionSummary> Versions,
|
||||
IReadOnlyList<ReleaseActivityProjection> RecentActivity,
|
||||
IReadOnlyList<ReleaseApprovalProjection> Approvals);
|
||||
|
||||
public sealed record ReleaseActivityProjection(
|
||||
string ActivityId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string EventType,
|
||||
string Status,
|
||||
string? TargetEnvironment,
|
||||
string? TargetRegion,
|
||||
string ActorId,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationKey);
|
||||
|
||||
public sealed record ReleaseApprovalProjection(
|
||||
string ApprovalId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string Status,
|
||||
string RequestedBy,
|
||||
DateTimeOffset RequestedAt,
|
||||
string? SourceEnvironment,
|
||||
string? TargetEnvironment,
|
||||
string? TargetRegion,
|
||||
int RequiredApprovals,
|
||||
int CurrentApprovals,
|
||||
IReadOnlyList<string> Blockers);
|
||||
|
||||
public sealed record ReleaseRunProjection(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string ReleaseType,
|
||||
string ReleaseVersionId,
|
||||
int ReleaseVersionNumber,
|
||||
string ReleaseVersionDigest,
|
||||
string Lane,
|
||||
string Status,
|
||||
string Outcome,
|
||||
bool NeedsApproval,
|
||||
bool BlockedByDataIntegrity,
|
||||
bool ReplayMismatch,
|
||||
string GateStatus,
|
||||
string EvidenceStatus,
|
||||
string? TargetEnvironment,
|
||||
string? TargetRegion,
|
||||
string RequestedBy,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string CorrelationKey);
|
||||
|
||||
public sealed record ReleaseRunStatusRow(
|
||||
string RunStatus,
|
||||
string GateStatus,
|
||||
string ApprovalStatus,
|
||||
string DataTrustStatus);
|
||||
|
||||
public sealed record ReleaseRunProcessStep(
|
||||
string StepId,
|
||||
string Label,
|
||||
string State,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? CompletedAt);
|
||||
|
||||
public sealed record ReleaseRunDetailProjection(
|
||||
string RunId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string ReleaseSlug,
|
||||
string ReleaseType,
|
||||
string ReleaseVersionId,
|
||||
int ReleaseVersionNumber,
|
||||
string ReleaseVersionDigest,
|
||||
string Lane,
|
||||
string Status,
|
||||
string Outcome,
|
||||
string? TargetEnvironment,
|
||||
string? TargetRegion,
|
||||
string ScopeSummary,
|
||||
DateTimeOffset RequestedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
bool NeedsApproval,
|
||||
bool BlockedByDataIntegrity,
|
||||
string CorrelationKey,
|
||||
ReleaseRunStatusRow StatusRow,
|
||||
IReadOnlyList<ReleaseRunProcessStep> Process);
|
||||
|
||||
public sealed record ReleaseRunTimelineEventProjection(
|
||||
string EventId,
|
||||
string EventClass,
|
||||
string Phase,
|
||||
string Status,
|
||||
DateTimeOffset OccurredAt,
|
||||
string Message,
|
||||
string? SnapshotId,
|
||||
string? JobId,
|
||||
string? CapsuleId);
|
||||
|
||||
public sealed record ReleaseRunCorrelationReference(
|
||||
string Type,
|
||||
string Value,
|
||||
string? Route);
|
||||
|
||||
public sealed record ReleaseRunTimelineProjection(
|
||||
string RunId,
|
||||
IReadOnlyList<ReleaseRunTimelineEventProjection> Events,
|
||||
IReadOnlyList<ReleaseRunCorrelationReference> Correlations);
|
||||
|
||||
public sealed record ReleaseRunGateReasonCode(
|
||||
string Source,
|
||||
string Code,
|
||||
string Description);
|
||||
|
||||
public sealed record ReleaseRunGateBudgetContributor(
|
||||
string Category,
|
||||
decimal Delta,
|
||||
string Note);
|
||||
|
||||
public sealed record ReleaseRunGateDecisionProjection(
|
||||
string RunId,
|
||||
string SnapshotId,
|
||||
string Verdict,
|
||||
string PolicyPackVersion,
|
||||
string TrustWeightsVersion,
|
||||
string StalenessPolicy,
|
||||
int StalenessThresholdMinutes,
|
||||
string StalenessVerdict,
|
||||
decimal RiskBudgetDelta,
|
||||
IReadOnlyList<ReleaseRunGateBudgetContributor> RiskBudgetContributors,
|
||||
IReadOnlyList<ReleaseRunGateReasonCode> MachineReasonCodes,
|
||||
IReadOnlyList<ReleaseRunGateReasonCode> HumanReasonCodes,
|
||||
IReadOnlyList<string> Blockers,
|
||||
DateTimeOffset EvaluatedAt);
|
||||
|
||||
public sealed record ReleaseRunApprovalCheckpointProjection(
|
||||
string CheckpointId,
|
||||
string Name,
|
||||
int Order,
|
||||
string Status,
|
||||
string RequiredRole,
|
||||
string? ApproverId,
|
||||
DateTimeOffset? ApprovedAt,
|
||||
string? Signature,
|
||||
string? Rationale,
|
||||
string? EvidenceProofId);
|
||||
|
||||
public sealed record ReleaseRunApprovalsProjection(
|
||||
string RunId,
|
||||
IReadOnlyList<ReleaseRunApprovalCheckpointProjection> Checkpoints);
|
||||
|
||||
public sealed record ReleaseRunDeploymentTargetProjection(
|
||||
string TargetId,
|
||||
string TargetName,
|
||||
string Environment,
|
||||
string Region,
|
||||
string Strategy,
|
||||
string Phase,
|
||||
string Status,
|
||||
string ArtifactDigest,
|
||||
string LogRef,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record ReleaseRunRollbackTriggerProjection(
|
||||
string TriggerId,
|
||||
string TriggerType,
|
||||
string Threshold,
|
||||
bool Fired,
|
||||
DateTimeOffset? FiredAt,
|
||||
string Outcome);
|
||||
|
||||
public sealed record ReleaseRunDeploymentsProjection(
|
||||
string RunId,
|
||||
IReadOnlyList<ReleaseRunDeploymentTargetProjection> Targets,
|
||||
IReadOnlyList<ReleaseRunRollbackTriggerProjection> RollbackTriggers);
|
||||
|
||||
public sealed record ReleaseRunSecurityDrilldownProjection(
|
||||
string Label,
|
||||
string Route,
|
||||
string Query);
|
||||
|
||||
public sealed record ReleaseRunSecurityInputsProjection(
|
||||
string RunId,
|
||||
string SbomSnapshotId,
|
||||
DateTimeOffset SbomGeneratedAt,
|
||||
int SbomAgeMinutes,
|
||||
string ReachabilitySnapshotId,
|
||||
int ReachabilityCoveragePercent,
|
||||
int ReachabilityEvidenceAgeMinutes,
|
||||
int VexStatementsApplied,
|
||||
int ExceptionsApplied,
|
||||
string FeedFreshnessStatus,
|
||||
int? FeedFreshnessMinutes,
|
||||
string PolicyImpactStatement,
|
||||
IReadOnlyList<ReleaseRunSecurityDrilldownProjection> Drilldowns);
|
||||
|
||||
public sealed record ReleaseRunEvidenceProjection(
|
||||
string RunId,
|
||||
string DecisionCapsuleId,
|
||||
string CapsuleHash,
|
||||
string SignatureStatus,
|
||||
string TransparencyReceipt,
|
||||
string ChainCompleteness,
|
||||
string ReplayDeterminismVerdict,
|
||||
bool ReplayMismatch,
|
||||
IReadOnlyList<string> ExportFormats,
|
||||
string CapsuleRoute,
|
||||
string VerifyRoute);
|
||||
|
||||
public sealed record ReleaseRunKnownGoodReferenceProjection(
|
||||
string ReferenceType,
|
||||
string ReferenceId,
|
||||
string Description);
|
||||
|
||||
public sealed record ReleaseRunRollbackEventProjection(
|
||||
string EventId,
|
||||
string Trigger,
|
||||
string Outcome,
|
||||
DateTimeOffset OccurredAt,
|
||||
string EvidenceId,
|
||||
string AuditId);
|
||||
|
||||
public sealed record ReleaseRunRollbackProjection(
|
||||
string RunId,
|
||||
string Readiness,
|
||||
bool ActionEnabled,
|
||||
IReadOnlyList<ReleaseRunKnownGoodReferenceProjection> KnownGoodReferences,
|
||||
IReadOnlyList<ReleaseRunRollbackEventProjection> History);
|
||||
|
||||
public sealed record ReleaseRunReplayProjection(
|
||||
string RunId,
|
||||
string Verdict,
|
||||
bool Match,
|
||||
string? MismatchReportId,
|
||||
DateTimeOffset EvaluatedAt,
|
||||
string ReplayLogReference);
|
||||
|
||||
public sealed record ReleaseRunAuditEntryProjection(
|
||||
string AuditId,
|
||||
string Category,
|
||||
string Action,
|
||||
string ActorId,
|
||||
DateTimeOffset OccurredAt,
|
||||
string CorrelationKey,
|
||||
string? Notes);
|
||||
|
||||
public sealed record ReleaseRunAuditProjection(
|
||||
string RunId,
|
||||
IReadOnlyList<ReleaseRunAuditEntryProjection> Entries);
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record SecurityFindingProjection(
|
||||
string FindingId,
|
||||
string CveId,
|
||||
string Severity,
|
||||
string PackageName,
|
||||
string ComponentName,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string Environment,
|
||||
string Region,
|
||||
bool Reachable,
|
||||
int ReachabilityScore,
|
||||
string EffectiveDisposition,
|
||||
string VexStatus,
|
||||
string ExceptionStatus,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record SecurityPivotBucket(
|
||||
string PivotValue,
|
||||
int FindingCount,
|
||||
int CriticalCount,
|
||||
int ReachableCount);
|
||||
|
||||
public sealed record SecurityFacetBucket(
|
||||
string Facet,
|
||||
string Value,
|
||||
int Count);
|
||||
|
||||
public sealed record SecurityFindingsResponse(
|
||||
string TenantId,
|
||||
string ActorId,
|
||||
DateTimeOffset AsOfUtc,
|
||||
bool Cached,
|
||||
int CacheTtlSeconds,
|
||||
IReadOnlyList<SecurityFindingProjection> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset,
|
||||
string Pivot,
|
||||
IReadOnlyList<SecurityPivotBucket> PivotBuckets,
|
||||
IReadOnlyList<SecurityFacetBucket> Facets);
|
||||
|
||||
public sealed record SecurityVexState(
|
||||
string Status,
|
||||
string Justification,
|
||||
string SourceModel,
|
||||
string? StatementId,
|
||||
DateTimeOffset? UpdatedAt);
|
||||
|
||||
public sealed record SecurityExceptionState(
|
||||
string Status,
|
||||
string Reason,
|
||||
string ApprovalState,
|
||||
string SourceModel,
|
||||
string? ExceptionId,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset? UpdatedAt);
|
||||
|
||||
public sealed record SecurityDispositionProjection(
|
||||
string FindingId,
|
||||
string CveId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string PackageName,
|
||||
string ComponentName,
|
||||
string Environment,
|
||||
string Region,
|
||||
SecurityVexState Vex,
|
||||
SecurityExceptionState Exception,
|
||||
string EffectiveDisposition,
|
||||
string PolicyAction,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record SecuritySbomComponentRow(
|
||||
string ComponentId,
|
||||
string ReleaseId,
|
||||
string ReleaseName,
|
||||
string Environment,
|
||||
string Region,
|
||||
string PackageName,
|
||||
string ComponentName,
|
||||
string ComponentVersion,
|
||||
string Supplier,
|
||||
string License,
|
||||
int VulnerabilityCount,
|
||||
int CriticalReachableCount,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record SecuritySbomGraphNode(
|
||||
string NodeId,
|
||||
string NodeType,
|
||||
string Label,
|
||||
string Region,
|
||||
string Environment);
|
||||
|
||||
public sealed record SecuritySbomGraphEdge(
|
||||
string EdgeId,
|
||||
string FromNodeId,
|
||||
string ToNodeId,
|
||||
string RelationType);
|
||||
|
||||
public sealed record SecuritySbomDiffRow(
|
||||
string ComponentName,
|
||||
string PackageName,
|
||||
string ChangeType,
|
||||
string? FromVersion,
|
||||
string? ToVersion,
|
||||
string Region,
|
||||
string Environment);
|
||||
|
||||
public sealed record SecuritySbomExplorerResponse(
|
||||
string TenantId,
|
||||
string ActorId,
|
||||
DateTimeOffset AsOfUtc,
|
||||
bool Cached,
|
||||
int CacheTtlSeconds,
|
||||
string Mode,
|
||||
IReadOnlyList<SecuritySbomComponentRow> Table,
|
||||
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
|
||||
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges,
|
||||
IReadOnlyList<SecuritySbomDiffRow> Diff,
|
||||
int TotalComponents,
|
||||
int Limit,
|
||||
int Offset);
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record TopologyRegionProjection(
|
||||
string RegionId,
|
||||
string DisplayName,
|
||||
int SortOrder,
|
||||
int EnvironmentCount,
|
||||
int TargetCount,
|
||||
int HostCount,
|
||||
int AgentCount,
|
||||
DateTimeOffset? LastSyncAt);
|
||||
|
||||
public sealed record TopologyEnvironmentProjection(
|
||||
string EnvironmentId,
|
||||
string RegionId,
|
||||
string EnvironmentType,
|
||||
string DisplayName,
|
||||
int SortOrder,
|
||||
int TargetCount,
|
||||
int HostCount,
|
||||
int AgentCount,
|
||||
int PromotionPathCount,
|
||||
int WorkflowCount,
|
||||
DateTimeOffset? LastSyncAt);
|
||||
|
||||
public sealed record TopologyTargetProjection(
|
||||
string TargetId,
|
||||
string Name,
|
||||
string RegionId,
|
||||
string EnvironmentId,
|
||||
string HostId,
|
||||
string AgentId,
|
||||
string TargetType,
|
||||
string HealthStatus,
|
||||
string ComponentVersionId,
|
||||
string ImageDigest,
|
||||
string ReleaseId,
|
||||
string ReleaseVersionId,
|
||||
DateTimeOffset? LastSyncAt);
|
||||
|
||||
public sealed record TopologyHostProjection(
|
||||
string HostId,
|
||||
string HostName,
|
||||
string RegionId,
|
||||
string EnvironmentId,
|
||||
string RuntimeType,
|
||||
string Status,
|
||||
string AgentId,
|
||||
int TargetCount,
|
||||
DateTimeOffset? LastSeenAt);
|
||||
|
||||
public sealed record TopologyAgentProjection(
|
||||
string AgentId,
|
||||
string AgentName,
|
||||
string RegionId,
|
||||
string EnvironmentId,
|
||||
string Status,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
int AssignedTargetCount,
|
||||
DateTimeOffset? LastHeartbeatAt);
|
||||
|
||||
public sealed record TopologyPromotionPathProjection(
|
||||
string PathId,
|
||||
string RegionId,
|
||||
string SourceEnvironmentId,
|
||||
string TargetEnvironmentId,
|
||||
string PathMode,
|
||||
string Status,
|
||||
int RequiredApprovals,
|
||||
string WorkflowId,
|
||||
string GateProfileId,
|
||||
DateTimeOffset? LastPromotedAt);
|
||||
|
||||
public sealed record TopologyWorkflowProjection(
|
||||
string WorkflowId,
|
||||
string WorkflowName,
|
||||
string RegionId,
|
||||
string EnvironmentId,
|
||||
string TriggerType,
|
||||
string Status,
|
||||
int StepCount,
|
||||
string GateProfileId,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record TopologyGateProfileProjection(
|
||||
string GateProfileId,
|
||||
string ProfileName,
|
||||
string RegionId,
|
||||
string EnvironmentId,
|
||||
string PolicyProfile,
|
||||
int RequiredApprovals,
|
||||
bool SeparationOfDuties,
|
||||
IReadOnlyList<string> BlockingRules,
|
||||
DateTimeOffset UpdatedAt);
|
||||
@@ -0,0 +1,132 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class ContextEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapContextEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var context = app.MapGroup("/api/v2/context")
|
||||
.WithTags("Platform Context");
|
||||
|
||||
context.MapGet("/regions", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformContextService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(httpContext, resolver, out _, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var regions = await service.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(regions);
|
||||
})
|
||||
.WithName("GetPlatformContextRegions")
|
||||
.WithSummary("List global regions for context selection")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
context.MapGet("/environments", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformContextService service,
|
||||
[FromQuery(Name = "regions")] string? regions,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(httpContext, resolver, out _, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var regionFilter = ParseCsv(regions);
|
||||
var environments = await service.GetEnvironmentsAsync(regionFilter, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(environments);
|
||||
})
|
||||
.WithName("GetPlatformContextEnvironments")
|
||||
.WithSummary("List global environments with optional region filter")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
context.MapGet("/preferences", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformContextService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(httpContext, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var preferences = await service.GetPreferencesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(preferences);
|
||||
})
|
||||
.WithName("GetPlatformContextPreferences")
|
||||
.WithSummary("Get persisted context preferences for the current user")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
context.MapPut("/preferences", async Task<IResult>(
|
||||
HttpContext httpContext,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformContextService service,
|
||||
PlatformContextPreferencesRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(httpContext, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var preferences = await service.UpsertPreferencesAsync(
|
||||
requestContext!,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(preferences);
|
||||
})
|
||||
.WithName("UpdatePlatformContextPreferences")
|
||||
.WithSummary("Update persisted context preferences for the current user")
|
||||
.RequireAuthorization(PlatformPolicies.ContextWrite);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string[] ParseCsv(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class IntegrationReadModelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIntegrationReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var integrations = app.MapGroup("/api/v2/integrations")
|
||||
.WithTags("Integrations V2");
|
||||
|
||||
integrations.MapGet("/feeds", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IntegrationsReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] IntegrationListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListFeedsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Status,
|
||||
query.SourceType,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<IntegrationFeedProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListIntegrationFeedsV2")
|
||||
.WithSummary("List advisory feed health/freshness integration projection")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
|
||||
|
||||
integrations.MapGet("/vex-sources", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IntegrationsReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] IntegrationListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListVexSourcesAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Status,
|
||||
query.SourceType,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<IntegrationVexSourceProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListIntegrationVexSourcesV2")
|
||||
.WithSummary("List VEX source health/freshness integration projection")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsVexRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed record IntegrationListQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Status,
|
||||
string? SourceType,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class LegacyAliasEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLegacyAliasEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var legacy = app.MapGroup("/api/v1")
|
||||
.WithTags("Pack22 Legacy Aliases");
|
||||
|
||||
legacy.MapGet("/context/regions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformContextService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var regions = await service.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(regions);
|
||||
})
|
||||
.WithName("GetPlatformContextRegionsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 context regions")
|
||||
.RequireAuthorization(PlatformPolicies.ContextRead);
|
||||
|
||||
legacy.MapGet("/releases", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacyReleaseListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListReleasesAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Type,
|
||||
query.Status,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleasesV1Alias")
|
||||
.WithSummary("Legacy alias for v2 releases projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
var runAliases = legacy.MapGroup("/releases/runs");
|
||||
|
||||
runAliases.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacyRunListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListRunsAsync(
|
||||
requestContext!,
|
||||
query.Status,
|
||||
query.Lane,
|
||||
query.Environment,
|
||||
query.Region,
|
||||
query.Outcome,
|
||||
query.NeedsApproval,
|
||||
query.BlockedByDataIntegrity,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseRunProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleaseRunsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run list projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunDetailAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunDetailV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run detail projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/timeline", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunTimelineAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunTimelineV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run timeline projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/gate-decision", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunGateDecisionAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunGateDecisionV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run gate decision projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/approvals", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunApprovalsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunApprovalsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run approvals projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/deployments", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunDeploymentsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunDeploymentsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run deployments projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/security-inputs", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunSecurityInputsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunSecurityInputsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run security inputs projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/evidence", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunEvidenceAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunEvidenceV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run evidence projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/rollback", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunRollbackAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunRollbackV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run rollback projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/replay", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunReplayAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunReplayV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run replay projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runAliases.MapGet("/{runId:guid}/audit", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunAuditAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return ToRunItemResponse(requestContext!, timeProvider, runId, item);
|
||||
})
|
||||
.WithName("GetReleaseRunAuditV1Alias")
|
||||
.WithSummary("Legacy alias for v2 run audit projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
legacy.MapGet("/topology/regions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacyTopologyRegionQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListRegionsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyRegionProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyRegionsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 topology regions projection")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
legacy.MapGet("/security/findings", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
SecurityReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacySecurityFindingsQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListFindingsAsync(
|
||||
requestContext!,
|
||||
query.Pivot,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Severity,
|
||||
query.Disposition,
|
||||
query.Search,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new SecurityFindingsResponse(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset,
|
||||
page.Pivot,
|
||||
page.PivotBuckets,
|
||||
page.Facets));
|
||||
})
|
||||
.WithName("ListSecurityFindingsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 security findings projection")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
legacy.MapGet("/integrations/feeds", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IntegrationsReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacyIntegrationQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListFeedsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Status,
|
||||
query.SourceType,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<IntegrationFeedProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListIntegrationFeedsV1Alias")
|
||||
.WithSummary("Legacy alias for v2 integrations feed projection")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsRead);
|
||||
|
||||
legacy.MapGet("/integrations/vex-sources", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IntegrationsReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] LegacyIntegrationQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListVexSourcesAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Status,
|
||||
query.SourceType,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<IntegrationVexSourceProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListIntegrationVexSourcesV1Alias")
|
||||
.WithSummary("Legacy alias for v2 integrations VEX source projection")
|
||||
.RequireAuthorization(PlatformPolicies.IntegrationsVexRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IResult ToRunItemResponse<TProjection>(
|
||||
PlatformRequestContext requestContext,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
TProjection? projection)
|
||||
where TProjection : class
|
||||
{
|
||||
if (projection is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "run_not_found", runId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<TProjection>(
|
||||
requestContext.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
projection));
|
||||
}
|
||||
|
||||
public sealed record LegacyReleaseListQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Type,
|
||||
string? Status,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record LegacyRunListQuery(
|
||||
string? Status,
|
||||
string? Lane,
|
||||
string? Environment,
|
||||
string? Region,
|
||||
string? Outcome,
|
||||
bool? NeedsApproval,
|
||||
bool? BlockedByDataIntegrity,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record LegacyTopologyRegionQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record LegacySecurityFindingsQuery(
|
||||
string? Pivot,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Severity,
|
||||
string? Disposition,
|
||||
string? Search,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record LegacyIntegrationQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Status,
|
||||
string? SourceType,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class ReleaseReadModelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapReleaseReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var releases = app.MapGroup("/api/v2/releases")
|
||||
.WithTags("Releases V2");
|
||||
|
||||
releases.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] ReleaseListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListReleasesAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Type,
|
||||
query.Status,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleasesV2")
|
||||
.WithSummary("List Pack-22 release projections")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
releases.MapGet("/{releaseId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid releaseId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var detail = await service.GetReleaseDetailAsync(
|
||||
requestContext!,
|
||||
releaseId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (detail is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "release_not_found", releaseId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<ReleaseDetailProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
detail));
|
||||
})
|
||||
.WithName("GetReleaseDetailV2")
|
||||
.WithSummary("Get Pack-22 release detail projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
releases.MapGet("/activity", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] ReleaseActivityQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListActivityAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseActivityProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleaseActivityV2")
|
||||
.WithSummary("List cross-release activity timeline")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
releases.MapGet("/approvals", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] ReleaseApprovalsQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListApprovalsAsync(
|
||||
requestContext!,
|
||||
query.Status,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseApprovalProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleaseApprovalsV2")
|
||||
.WithSummary("List cross-release approvals queue projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
var runs = releases.MapGroup("/runs");
|
||||
|
||||
runs.MapGet(string.Empty, async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] ReleaseRunListQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListRunsAsync(
|
||||
requestContext!,
|
||||
query.Status,
|
||||
query.Lane,
|
||||
query.Environment,
|
||||
query.Region,
|
||||
query.Outcome,
|
||||
query.NeedsApproval,
|
||||
query.BlockedByDataIntegrity,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<ReleaseRunProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListReleaseRunsV2")
|
||||
.WithSummary("List run-centric release projections for Pack-22 contracts")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunDetailAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunDetailProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunDetailV2")
|
||||
.WithSummary("Get canonical release run detail projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/timeline", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunTimelineAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunTimelineProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunTimelineV2")
|
||||
.WithSummary("Get release run timeline projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/gate-decision", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunGateDecisionAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunGateDecisionProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunGateDecisionV2")
|
||||
.WithSummary("Get release run gate decision projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/approvals", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunApprovalsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunApprovalsProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunApprovalsV2")
|
||||
.WithSummary("Get release run approvals checkpoints projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/deployments", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunDeploymentsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunDeploymentsProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunDeploymentsV2")
|
||||
.WithSummary("Get release run deployments projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/security-inputs", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunSecurityInputsAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunSecurityInputsProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunSecurityInputsV2")
|
||||
.WithSummary("Get release run security inputs projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/evidence", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunEvidenceAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunEvidenceProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunEvidenceV2")
|
||||
.WithSummary("Get release run evidence capsule projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/rollback", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunRollbackAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunRollbackProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunRollbackV2")
|
||||
.WithSummary("Get release run rollback projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/replay", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunReplayAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunReplayProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunReplayV2")
|
||||
.WithSummary("Get release run replay projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
runs.MapGet("/{runId:guid}/audit", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
ReleaseReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetRunAuditAsync(requestContext!, runId, cancellationToken).ConfigureAwait(false);
|
||||
return item is null
|
||||
? Results.NotFound(new { error = "run_not_found", runId })
|
||||
: Results.Ok(new PlatformItemResponse<ReleaseRunAuditProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetReleaseRunAuditV2")
|
||||
.WithSummary("Get release run audit projection")
|
||||
.RequireAuthorization(PlatformPolicies.ReleaseControlRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed record ReleaseListQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Type,
|
||||
string? Status,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record ReleaseActivityQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record ReleaseApprovalsQuery(
|
||||
string? Status,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record ReleaseRunListQuery(
|
||||
string? Status,
|
||||
string? Lane,
|
||||
string? Environment,
|
||||
string? Region,
|
||||
string? Outcome,
|
||||
bool? NeedsApproval,
|
||||
bool? BlockedByDataIntegrity,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class SecurityReadModelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSecurityReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var security = app.MapGroup("/api/v2/security")
|
||||
.WithTags("Security V2");
|
||||
|
||||
security.MapGet("/findings", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
SecurityReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] SecurityFindingsQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListFindingsAsync(
|
||||
requestContext!,
|
||||
query.Pivot,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Severity,
|
||||
query.Disposition,
|
||||
query.Search,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new SecurityFindingsResponse(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset,
|
||||
page.Pivot,
|
||||
page.PivotBuckets,
|
||||
page.Facets));
|
||||
})
|
||||
.WithName("ListSecurityFindingsV2")
|
||||
.WithSummary("List consolidated security findings with pivot/facet schema")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
security.MapGet("/disposition", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
SecurityReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] SecurityDispositionQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListDispositionAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Status,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<SecurityDispositionProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListSecurityDispositionV2")
|
||||
.WithSummary("List consolidated security disposition projection (VEX + exceptions read-join)")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
security.MapGet("/disposition/{findingId}", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
SecurityReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var item = await service.GetDispositionAsync(
|
||||
requestContext!,
|
||||
findingId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (item is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "finding_not_found", findingId });
|
||||
}
|
||||
|
||||
return Results.Ok(new PlatformItemResponse<SecurityDispositionProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
item));
|
||||
})
|
||||
.WithName("GetSecurityDispositionV2")
|
||||
.WithSummary("Get consolidated security disposition by finding id")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
security.MapGet("/sbom-explorer", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
SecurityReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] SecuritySbomExplorerQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetSbomExplorerAsync(
|
||||
requestContext!,
|
||||
query.Mode,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.LeftReleaseId,
|
||||
query.RightReleaseId,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new SecuritySbomExplorerResponse(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
Mode: result.Mode,
|
||||
Table: result.Table,
|
||||
GraphNodes: result.GraphNodes,
|
||||
GraphEdges: result.GraphEdges,
|
||||
Diff: result.Diff,
|
||||
TotalComponents: result.TotalComponents,
|
||||
Limit: result.Limit,
|
||||
Offset: result.Offset));
|
||||
})
|
||||
.WithName("GetSecuritySbomExplorerV2")
|
||||
.WithSummary("Get consolidated SBOM explorer projection (table/graph/diff)")
|
||||
.RequireAuthorization(PlatformPolicies.SecurityRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed record SecurityFindingsQuery(
|
||||
string? Pivot,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Severity,
|
||||
string? Disposition,
|
||||
string? Search,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record SecurityDispositionQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? Status,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
|
||||
public sealed record SecuritySbomExplorerQuery(
|
||||
string? Mode,
|
||||
string? Region,
|
||||
string? Environment,
|
||||
string? LeftReleaseId,
|
||||
string? RightReleaseId,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
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.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class TopologyReadModelEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTopologyReadModelEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var topology = app.MapGroup("/api/v2/topology")
|
||||
.WithTags("Topology V2");
|
||||
|
||||
topology.MapGet("/regions", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListRegionsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyRegionProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyRegionsV2")
|
||||
.WithSummary("List topology regions")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/environments", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListEnvironmentsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyEnvironmentProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyEnvironmentsV2")
|
||||
.WithSummary("List topology environments")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/targets", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListTargetsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyTargetProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyTargetsV2")
|
||||
.WithSummary("List topology targets")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/hosts", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListHostsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyHostProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyHostsV2")
|
||||
.WithSummary("List topology hosts")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/agents", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListAgentsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyAgentProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyAgentsV2")
|
||||
.WithSummary("List topology agents")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/promotion-paths", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListPromotionPathsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyPromotionPathProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyPromotionPathsV2")
|
||||
.WithSummary("List topology promotion paths")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/workflows", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListWorkflowsAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyWorkflowProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyWorkflowsV2")
|
||||
.WithSummary("List topology workflows")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
topology.MapGet("/gate-profiles", async Task<IResult>(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
TopologyReadModelService service,
|
||||
TimeProvider timeProvider,
|
||||
[AsParameters] TopologyQuery query,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var page = await service.ListGateProfilesAsync(
|
||||
requestContext!,
|
||||
query.Region,
|
||||
query.Environment,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<TopologyGateProfileProjection>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
timeProvider.GetUtcNow(),
|
||||
Cached: false,
|
||||
CacheTtlSeconds: 0,
|
||||
page.Items,
|
||||
page.Total,
|
||||
page.Limit,
|
||||
page.Offset));
|
||||
})
|
||||
.WithName("ListTopologyGateProfilesV2")
|
||||
.WithSummary("List topology gate profiles")
|
||||
.RequireAuthorization(PlatformPolicies.TopologyRead);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
public sealed record TopologyQuery(
|
||||
string? Region,
|
||||
string? Environment,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
@@ -122,6 +122,12 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesWrite, PlatformScopes.PreferencesWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ContextRead, PlatformScopes.ContextRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ContextWrite, PlatformScopes.ContextWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.TopologyRead, PlatformScopes.OrchRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SecurityRead, PlatformScopes.FindingsRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IntegrationsRead, PlatformScopes.AdvisoryRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IntegrationsVexRead, PlatformScopes.VexRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.SearchRead, PlatformScopes.SearchRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.MetadataRead, PlatformScopes.MetadataRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.AnalyticsRead, PlatformScopes.AnalyticsRead);
|
||||
@@ -148,6 +154,7 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
builder.Services.AddSingleton<PlatformCache>();
|
||||
builder.Services.AddSingleton<PlatformAggregationMetrics>();
|
||||
builder.Services.AddSingleton<LegacyAliasTelemetry>();
|
||||
|
||||
builder.Services.AddSingleton<PlatformQuotaAlertStore>();
|
||||
builder.Services.AddSingleton<PlatformQuotaService>();
|
||||
@@ -163,6 +170,11 @@ builder.Services.AddSingleton<PlatformPreferencesService>();
|
||||
|
||||
builder.Services.AddSingleton<PlatformSearchService>();
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<TopologyReadModelService>();
|
||||
builder.Services.AddSingleton<ReleaseReadModelService>();
|
||||
builder.Services.AddSingleton<SecurityReadModelService>();
|
||||
builder.Services.AddSingleton<IntegrationsReadModelService>();
|
||||
builder.Services.AddSingleton<PlatformAnalyticsDataSource>();
|
||||
builder.Services.AddSingleton<IPlatformAnalyticsQueryExecutor, PlatformAnalyticsQueryExecutor>();
|
||||
builder.Services.AddSingleton<IPlatformAnalyticsMaintenanceExecutor, PlatformAnalyticsMaintenanceExecutor>();
|
||||
@@ -192,6 +204,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, PostgresEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -199,6 +212,7 @@ else
|
||||
builder.Services.AddSingleton<IEnvironmentSettingsStore, InMemoryEnvironmentSettingsStore>();
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, InMemoryReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, InMemoryPlatformContextStore>();
|
||||
}
|
||||
|
||||
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
|
||||
@@ -242,8 +256,19 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.TryUseStellaRouter(routerOptions);
|
||||
|
||||
var legacyAliasTelemetry = app.Services.GetRequiredService<LegacyAliasTelemetry>();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
await next(context).ConfigureAwait(false);
|
||||
if (context.Response.StatusCode != StatusCodes.Status404NotFound)
|
||||
{
|
||||
legacyAliasTelemetry.Record(context);
|
||||
}
|
||||
});
|
||||
|
||||
app.MapEnvironmentSettingsEndpoints();
|
||||
app.MapEnvironmentSettingsAdminEndpoints();
|
||||
app.MapContextEndpoints();
|
||||
app.MapPlatformEndpoints();
|
||||
app.MapSetupEndpoints();
|
||||
app.MapAnalyticsEndpoints();
|
||||
@@ -251,6 +276,11 @@ app.MapScoreEndpoints();
|
||||
app.MapFunctionMapEndpoints();
|
||||
app.MapPolicyInteropEndpoints();
|
||||
app.MapReleaseControlEndpoints();
|
||||
app.MapReleaseReadModelEndpoints();
|
||||
app.MapTopologyReadModelEndpoints();
|
||||
app.MapSecurityReadModelEndpoints();
|
||||
app.MapIntegrationReadModelEndpoints();
|
||||
app.MapLegacyAliasEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
|
||||
@@ -48,4 +48,22 @@ public interface IReleaseControlBundleStore
|
||||
Guid versionId,
|
||||
MaterializeReleaseControlBundleVersionRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
|
||||
string tenantId,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -280,6 +280,69 @@ public sealed class InMemoryReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var list = state.Materializations.Values
|
||||
.OrderByDescending(run => run.RequestedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleMaterializationRun>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
var list = state.Materializations.Values
|
||||
.Where(run => run.BundleId == bundleId)
|
||||
.OrderByDescending(run => run.RequestedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.Skip(Math.Max(offset, 0))
|
||||
.Take(Math.Max(limit, 1))
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<ReleaseControlBundleMaterializationRun>>(list);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
|
||||
string tenantId,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var state = GetState(tenantId);
|
||||
|
||||
lock (state.Sync)
|
||||
{
|
||||
return Task.FromResult(
|
||||
state.Materializations.TryGetValue(runId, out var run)
|
||||
? run
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantState GetState(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class IntegrationsReadModelService
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
private const int RunScanLimit = 1000;
|
||||
|
||||
private static readonly IReadOnlyList<string> FeedConsumerDomains = ["security-findings", "dashboard-posture"];
|
||||
private static readonly IReadOnlyList<string> VexConsumerDomains = ["security-disposition", "dashboard-posture"];
|
||||
|
||||
private static readonly FeedSourceDefinition[] FeedSources =
|
||||
[
|
||||
new("feed-nvd", "NVD", "advisory_feed", "nvd", 60),
|
||||
new("feed-osv", "OSV", "advisory_feed", "osv", 90),
|
||||
new("feed-kev", "KEV", "advisory_feed", "cisa", 120),
|
||||
new("feed-vendor", "Vendor Advisories", "advisory_feed", "vendor", 180)
|
||||
];
|
||||
|
||||
private static readonly VexSourceDefinition[] VexSources =
|
||||
[
|
||||
new("vex-vendor", "Vendor VEX", "vex_source", "vendor", 180, "openvex"),
|
||||
new("vex-internal", "Internal VEX", "vex_source", "internal", 120, "openvex")
|
||||
];
|
||||
|
||||
private readonly IReleaseControlBundleStore bundleStore;
|
||||
private readonly PlatformContextService contextService;
|
||||
|
||||
public IntegrationsReadModelService(
|
||||
IReleaseControlBundleStore bundleStore,
|
||||
PlatformContextService contextService)
|
||||
{
|
||||
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
|
||||
}
|
||||
|
||||
public async Task<IntegrationPageResult<IntegrationFeedProjection>> ListFeedsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? status,
|
||||
string? sourceType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedStatus = NormalizeOptional(status);
|
||||
var normalizedSourceType = NormalizeOptional(sourceType);
|
||||
|
||||
var filtered = snapshot.Feeds
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.Status, normalizedStatus, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedSourceType) || string.Equals(item.SourceType, normalizedSourceType, StringComparison.Ordinal)))
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<IntegrationPageResult<IntegrationVexSourceProjection>> ListVexSourcesAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? status,
|
||||
string? sourceType,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedStatus = NormalizeOptional(status);
|
||||
var normalizedSourceType = NormalizeOptional(sourceType);
|
||||
|
||||
var filtered = snapshot.VexSources
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.Status, normalizedStatus, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedSourceType) || string.Equals(item.SourceType, normalizedSourceType, StringComparison.Ordinal)))
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
private async Task<IntegrationSnapshot> BuildSnapshotAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
var runs = await bundleStore.ListMaterializationRunsAsync(
|
||||
context.TenantId,
|
||||
RunScanLimit,
|
||||
0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestRunByEnvironment = runs
|
||||
.Select(run => new
|
||||
{
|
||||
EnvironmentId = NormalizeOptional(run.TargetEnvironment),
|
||||
Run = run
|
||||
})
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item.EnvironmentId))
|
||||
.GroupBy(item => item.EnvironmentId!, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.Select(item => item.Run)
|
||||
.OrderByDescending(run => run.UpdatedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.First(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var feeds = new List<IntegrationFeedProjection>(environments.Count * FeedSources.Length);
|
||||
var vexSources = new List<IntegrationVexSourceProjection>(environments.Count * VexSources.Length);
|
||||
|
||||
foreach (var environment in environments
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
latestRunByEnvironment.TryGetValue(environment.EnvironmentId, out var latestRun);
|
||||
|
||||
foreach (var definition in FeedSources)
|
||||
{
|
||||
var sourceHealth = BuildSourceHealth(
|
||||
context.TenantId,
|
||||
environment.RegionId,
|
||||
environment.EnvironmentId,
|
||||
definition.SourceId,
|
||||
definition.SlaMinutes,
|
||||
latestRun);
|
||||
|
||||
feeds.Add(new IntegrationFeedProjection(
|
||||
SourceId: $"{definition.SourceId}-{environment.EnvironmentId}",
|
||||
SourceName: definition.SourceName,
|
||||
SourceType: definition.SourceType,
|
||||
Provider: definition.Provider,
|
||||
Region: environment.RegionId,
|
||||
Environment: environment.EnvironmentId,
|
||||
Status: sourceHealth.Status,
|
||||
Freshness: sourceHealth.Freshness,
|
||||
LastSyncAt: sourceHealth.LastSyncAt,
|
||||
FreshnessMinutes: sourceHealth.FreshnessMinutes,
|
||||
SlaMinutes: definition.SlaMinutes,
|
||||
LastSuccessAt: sourceHealth.LastSuccessAt,
|
||||
LastError: sourceHealth.LastError,
|
||||
ConsumerDomains: FeedConsumerDomains));
|
||||
}
|
||||
|
||||
foreach (var definition in VexSources)
|
||||
{
|
||||
var sourceHealth = BuildSourceHealth(
|
||||
context.TenantId,
|
||||
environment.RegionId,
|
||||
environment.EnvironmentId,
|
||||
definition.SourceId,
|
||||
definition.SlaMinutes,
|
||||
latestRun);
|
||||
var digest = HashSeed($"{context.TenantId}:{environment.EnvironmentId}:{definition.SourceId}:documents");
|
||||
var documentCount = 20 + (ParseHexByte(digest, 0) % 180);
|
||||
|
||||
vexSources.Add(new IntegrationVexSourceProjection(
|
||||
SourceId: $"{definition.SourceId}-{environment.EnvironmentId}",
|
||||
SourceName: definition.SourceName,
|
||||
SourceType: definition.SourceType,
|
||||
Provider: definition.Provider,
|
||||
Region: environment.RegionId,
|
||||
Environment: environment.EnvironmentId,
|
||||
Status: sourceHealth.Status,
|
||||
Freshness: sourceHealth.Freshness,
|
||||
LastSyncAt: sourceHealth.LastSyncAt,
|
||||
FreshnessMinutes: sourceHealth.FreshnessMinutes,
|
||||
SlaMinutes: definition.SlaMinutes,
|
||||
StatementFormat: definition.StatementFormat,
|
||||
DocumentCount24h: documentCount,
|
||||
LastSuccessAt: sourceHealth.LastSuccessAt,
|
||||
LastError: sourceHealth.LastError,
|
||||
ConsumerDomains: VexConsumerDomains));
|
||||
}
|
||||
}
|
||||
|
||||
return new IntegrationSnapshot(
|
||||
feeds
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
vexSources
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceId, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static SourceHealthSnapshot BuildSourceHealth(
|
||||
string tenantId,
|
||||
string regionId,
|
||||
string environmentId,
|
||||
string sourceId,
|
||||
int slaMinutes,
|
||||
ReleaseControlBundleMaterializationRun? latestRun)
|
||||
{
|
||||
if (latestRun is null)
|
||||
{
|
||||
return new SourceHealthSnapshot(
|
||||
Status: "offline",
|
||||
Freshness: "unknown",
|
||||
LastSyncAt: null,
|
||||
FreshnessMinutes: null,
|
||||
LastSuccessAt: null,
|
||||
LastError: "source_sync_uninitialized");
|
||||
}
|
||||
|
||||
var digest = HashSeed($"{tenantId}:{regionId}:{environmentId}:{sourceId}:{latestRun.RunId:D}");
|
||||
var ageWindow = Math.Max(slaMinutes * 4, slaMinutes + 1);
|
||||
var freshnessMinutes = ParseHexByte(digest, 0) % ageWindow;
|
||||
var lastSyncAt = latestRun.UpdatedAt - TimeSpan.FromMinutes(freshnessMinutes);
|
||||
var status = ResolveStatus(freshnessMinutes, slaMinutes);
|
||||
var freshness = freshnessMinutes <= slaMinutes ? "fresh" : "stale";
|
||||
var lastSuccessAt = status == "offline"
|
||||
? lastSyncAt - TimeSpan.FromMinutes((ParseHexByte(digest, 1) % 120) + 1)
|
||||
: lastSyncAt;
|
||||
var lastError = status switch
|
||||
{
|
||||
"healthy" => null,
|
||||
"degraded" => "source_sync_delayed",
|
||||
_ => "source_sync_unreachable"
|
||||
};
|
||||
|
||||
return new SourceHealthSnapshot(
|
||||
Status: status,
|
||||
Freshness: freshness,
|
||||
LastSyncAt: lastSyncAt,
|
||||
FreshnessMinutes: freshnessMinutes,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastError: lastError);
|
||||
}
|
||||
|
||||
private static string ResolveStatus(int freshnessMinutes, int slaMinutes)
|
||||
{
|
||||
if (freshnessMinutes <= slaMinutes)
|
||||
{
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
if (freshnessMinutes <= (slaMinutes * 3))
|
||||
{
|
||||
return "degraded";
|
||||
}
|
||||
|
||||
return "offline";
|
||||
}
|
||||
|
||||
private static IntegrationPageResult<TItem> Page<TItem>(
|
||||
IReadOnlyList<TItem> items,
|
||||
int? limit,
|
||||
int? offset)
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var paged = items
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return new IntegrationPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value)
|
||||
{
|
||||
return value is null or < 0 ? 0 : value.Value;
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseFilterSet(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static item => NormalizeOptional(item))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static byte ParseHexByte(string digest, int index)
|
||||
{
|
||||
var offset = index * 2;
|
||||
if (offset + 2 > digest.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return byte.Parse(digest.AsSpan(offset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string HashSeed(string seed)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
var builder = new StringBuilder(bytes.Length * 2);
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record IntegrationSnapshot(
|
||||
IReadOnlyList<IntegrationFeedProjection> Feeds,
|
||||
IReadOnlyList<IntegrationVexSourceProjection> VexSources);
|
||||
|
||||
private sealed record FeedSourceDefinition(
|
||||
string SourceId,
|
||||
string SourceName,
|
||||
string SourceType,
|
||||
string Provider,
|
||||
int SlaMinutes);
|
||||
|
||||
private sealed record VexSourceDefinition(
|
||||
string SourceId,
|
||||
string SourceName,
|
||||
string SourceType,
|
||||
string Provider,
|
||||
int SlaMinutes,
|
||||
string StatementFormat);
|
||||
|
||||
private sealed record SourceHealthSnapshot(
|
||||
string Status,
|
||||
string Freshness,
|
||||
DateTimeOffset? LastSyncAt,
|
||||
int? FreshnessMinutes,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
string? LastError);
|
||||
}
|
||||
|
||||
public sealed record IntegrationPageResult<TItem>(
|
||||
IReadOnlyList<TItem> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset);
|
||||
@@ -0,0 +1,166 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class LegacyAliasTelemetry
|
||||
{
|
||||
private const int MaxEntries = 512;
|
||||
|
||||
private readonly ConcurrentQueue<LegacyAliasUsageEvent> events = new();
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<LegacyAliasTelemetry> logger;
|
||||
|
||||
public LegacyAliasTelemetry(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<LegacyAliasTelemetry> logger)
|
||||
{
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public void Record(HttpContext httpContext)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
|
||||
var endpoint = httpContext.GetEndpoint() as RouteEndpoint;
|
||||
var routePattern = endpoint?.RoutePattern.RawText ?? httpContext.Request.Path.Value ?? string.Empty;
|
||||
if (!routePattern.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var method = NormalizeMethod(httpContext.Request.Method);
|
||||
var eventKey = BuildEventKey(method, routePattern);
|
||||
var canonicalRoute = BuildCanonicalRoute(routePattern);
|
||||
var tenantHash = ComputeTenantHash(ReadHeader(httpContext, "X-StellaOps-Tenant"));
|
||||
var timestamp = timeProvider.GetUtcNow();
|
||||
|
||||
var usage = new LegacyAliasUsageEvent(
|
||||
eventKey,
|
||||
method,
|
||||
routePattern,
|
||||
canonicalRoute,
|
||||
httpContext.Response.StatusCode,
|
||||
tenantHash,
|
||||
timestamp);
|
||||
|
||||
events.Enqueue(usage);
|
||||
while (events.Count > MaxEntries && events.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Deprecated alias used event_key={EventKey} alias={AliasRoute} canonical={CanonicalRoute} method={Method} status={StatusCode} tenant_hash={TenantHash}",
|
||||
usage.EventKey,
|
||||
usage.AliasRoute,
|
||||
usage.CanonicalRoute,
|
||||
usage.Method,
|
||||
usage.StatusCode,
|
||||
usage.TenantHash ?? "none");
|
||||
}
|
||||
|
||||
public IReadOnlyList<LegacyAliasUsageEvent> Snapshot()
|
||||
{
|
||||
return events
|
||||
.ToArray()
|
||||
.OrderBy(item => item.RecordedAt)
|
||||
.ThenBy(item => item.EventKey, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
while (events.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCanonicalRoute(string routePattern)
|
||||
{
|
||||
return routePattern.StartsWith("/api/v1/", StringComparison.OrdinalIgnoreCase)
|
||||
? $"/api/v2/{routePattern["/api/v1/".Length..]}"
|
||||
: routePattern;
|
||||
}
|
||||
|
||||
private static string BuildEventKey(string method, string routePattern)
|
||||
{
|
||||
var normalizedRoute = NormalizeToken(routePattern);
|
||||
return $"alias_{method}_{normalizedRoute}";
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
var trimmed = value.Trim().ToLowerInvariant();
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
var previousWasUnderscore = false;
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(ch);
|
||||
previousWasUnderscore = false;
|
||||
}
|
||||
else if (!previousWasUnderscore)
|
||||
{
|
||||
builder.Append('_');
|
||||
previousWasUnderscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = builder.ToString().Trim('_');
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "unknown" : normalized;
|
||||
}
|
||||
|
||||
private static string? ComputeTenantHash(string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = tenantId.Trim().ToLowerInvariant();
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(normalized));
|
||||
return Convert.ToHexString(digest.AsSpan(0, 6)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string NormalizeMethod(string method)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(method)
|
||||
? "unknown"
|
||||
: method.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string? ReadHeader(HttpContext context, string headerName)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(headerName, out var values))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return values
|
||||
.Select(static value => value?.Trim())
|
||||
.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record LegacyAliasUsageEvent(
|
||||
string EventKey,
|
||||
string Method,
|
||||
string AliasRoute,
|
||||
string CanonicalRoute,
|
||||
int StatusCode,
|
||||
string? TenantHash,
|
||||
DateTimeOffset RecordedAt);
|
||||
@@ -0,0 +1,399 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public interface IPlatformContextStore
|
||||
{
|
||||
Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default);
|
||||
Task<PlatformContextPreferences?> GetPreferencesAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<PlatformContextPreferences> UpsertPreferencesAsync(
|
||||
PlatformContextPreferences preferences,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class PlatformContextService
|
||||
{
|
||||
private static readonly string[] AllowedTimeWindows = ["1h", "24h", "7d", "30d", "90d"];
|
||||
private const string DefaultTimeWindow = "24h";
|
||||
|
||||
private readonly IPlatformContextStore store;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly ILogger<PlatformContextService> logger;
|
||||
|
||||
public PlatformContextService(
|
||||
IPlatformContextStore store,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<PlatformContextService> logger)
|
||||
{
|
||||
this.store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var regions = await store.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
return regions
|
||||
.OrderBy(region => region.SortOrder)
|
||||
.ThenBy(region => region.RegionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(
|
||||
IReadOnlyList<string>? regionFilter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalizedRegions = NormalizeSelection(regionFilter);
|
||||
var environments = await store.GetEnvironmentsAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return environments
|
||||
.Where(environment =>
|
||||
normalizedRegions.Length == 0
|
||||
|| normalizedRegions.Contains(environment.RegionId, StringComparer.Ordinal))
|
||||
.OrderBy(environment => environment.SortOrder)
|
||||
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences> GetPreferencesAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var existing = await store.GetPreferencesAsync(
|
||||
context.TenantId,
|
||||
context.ActorId,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var defaultRegions = (await GetRegionsAsync(cancellationToken).ConfigureAwait(false))
|
||||
.Select(region => region.RegionId)
|
||||
.ToArray();
|
||||
var created = new PlatformContextPreferences(
|
||||
context.TenantId,
|
||||
context.ActorId,
|
||||
defaultRegions,
|
||||
Array.Empty<string>(),
|
||||
DefaultTimeWindow,
|
||||
timeProvider.GetUtcNow(),
|
||||
context.ActorId);
|
||||
|
||||
return await store.UpsertPreferencesAsync(created, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
|
||||
PlatformRequestContext context,
|
||||
PlatformContextPreferencesRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var current = await GetPreferencesAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regions = await GetRegionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var environments = await GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var orderedRegionIds = regions
|
||||
.Select(region => region.RegionId)
|
||||
.ToArray();
|
||||
var envById = environments
|
||||
.ToDictionary(environment => environment.EnvironmentId, StringComparer.Ordinal);
|
||||
var orderedEnvironmentIds = environments
|
||||
.Select(environment => environment.EnvironmentId)
|
||||
.ToArray();
|
||||
|
||||
var requestedRegions = request.Regions is null
|
||||
? NormalizeSelection(current.Regions)
|
||||
: NormalizeSelection(request.Regions);
|
||||
var requestedRegionSet = requestedRegions.ToHashSet(StringComparer.Ordinal);
|
||||
var nextRegions = orderedRegionIds
|
||||
.Where(requestedRegionSet.Contains)
|
||||
.ToArray();
|
||||
|
||||
if (nextRegions.Length == 0)
|
||||
{
|
||||
nextRegions = orderedRegionIds;
|
||||
}
|
||||
|
||||
var requestedEnvironments = request.Environments is null
|
||||
? NormalizeSelection(current.Environments)
|
||||
: NormalizeSelection(request.Environments);
|
||||
var requestedEnvironmentSet = requestedEnvironments.ToHashSet(StringComparer.Ordinal);
|
||||
var selectedRegionSet = nextRegions.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var nextEnvironments = orderedEnvironmentIds
|
||||
.Where(requestedEnvironmentSet.Contains)
|
||||
.Where(environmentId =>
|
||||
envById.TryGetValue(environmentId, out var environment)
|
||||
&& selectedRegionSet.Contains(environment.RegionId))
|
||||
.ToArray();
|
||||
|
||||
var nextTimeWindow = NormalizeTimeWindow(request.TimeWindow, current.TimeWindow);
|
||||
|
||||
var updated = new PlatformContextPreferences(
|
||||
context.TenantId,
|
||||
context.ActorId,
|
||||
nextRegions,
|
||||
nextEnvironments,
|
||||
nextTimeWindow,
|
||||
timeProvider.GetUtcNow(),
|
||||
context.ActorId);
|
||||
|
||||
logger.LogInformation(
|
||||
"Updated platform context preferences for tenant {TenantId}, actor {ActorId}",
|
||||
context.TenantId,
|
||||
context.ActorId);
|
||||
|
||||
return await store.UpsertPreferencesAsync(updated, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string NormalizeTimeWindow(string? requested, string fallback)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(requested))
|
||||
{
|
||||
var candidate = requested.Trim();
|
||||
if (AllowedTimeWindows.Contains(candidate, StringComparer.Ordinal))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (AllowedTimeWindows.Contains(fallback, StringComparer.Ordinal))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return DefaultTimeWindow;
|
||||
}
|
||||
|
||||
private static string[] NormalizeSelection(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(value => value, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class InMemoryPlatformContextStore : IPlatformContextStore
|
||||
{
|
||||
private static readonly IReadOnlyList<PlatformContextRegion> Regions =
|
||||
[
|
||||
new("apac", "APAC", 30),
|
||||
new("eu-west", "EU West", 20),
|
||||
new("us-east", "US East", 10),
|
||||
];
|
||||
|
||||
private static readonly IReadOnlyList<PlatformContextEnvironment> Environments =
|
||||
[
|
||||
new("apac-prod", "apac", "production", "APAC Production", 30),
|
||||
new("eu-prod", "eu-west", "production", "EU Production", 20),
|
||||
new("eu-stage", "eu-west", "staging", "EU Staging", 21),
|
||||
new("us-prod", "us-east", "production", "US Production", 10),
|
||||
new("us-uat", "us-east", "staging", "US UAT", 11),
|
||||
];
|
||||
|
||||
private readonly ConcurrentDictionary<PlatformUserKey, PlatformContextPreferences> preferences = new();
|
||||
|
||||
public Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Regions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(Environments);
|
||||
}
|
||||
|
||||
public Task<PlatformContextPreferences?> GetPreferencesAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = PlatformUserKey.Create(tenantId, actorId);
|
||||
preferences.TryGetValue(key, out var existing);
|
||||
return Task.FromResult(existing);
|
||||
}
|
||||
|
||||
public Task<PlatformContextPreferences> UpsertPreferencesAsync(
|
||||
PlatformContextPreferences preference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = PlatformUserKey.Create(preference.TenantId, preference.ActorId);
|
||||
preferences[key] = preference;
|
||||
return Task.FromResult(preference);
|
||||
}
|
||||
}
|
||||
|
||||
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 string UpsertPreferencesSql = """
|
||||
INSERT INTO platform.ui_context_preferences
|
||||
(tenant_id, actor_id, regions, environments, time_window, updated_at, updated_by)
|
||||
VALUES
|
||||
(@tenant_id, @actor_id, @regions, @environments, @time_window, @updated_at, @updated_by)
|
||||
ON CONFLICT (tenant_id, actor_id)
|
||||
DO UPDATE SET
|
||||
regions = EXCLUDED.regions,
|
||||
environments = EXCLUDED.environments,
|
||||
time_window = EXCLUDED.time_window,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
RETURNING regions, environments, time_window, updated_at, updated_by
|
||||
""";
|
||||
|
||||
private readonly NpgsqlDataSource dataSource;
|
||||
|
||||
public PostgresPlatformContextStore(NpgsqlDataSource dataSource)
|
||||
{
|
||||
this.dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
return regions;
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
return environments;
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences?> GetPreferencesAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
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 reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PlatformContextPreferences(
|
||||
tenantId,
|
||||
actorId,
|
||||
ReadTextArray(reader, 0),
|
||||
ReadTextArray(reader, 1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetString(4));
|
||||
}
|
||||
|
||||
public async Task<PlatformContextPreferences> UpsertPreferencesAsync(
|
||||
PlatformContextPreferences preference,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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);
|
||||
command.Parameters.AddWithValue("actor_id", preference.ActorId);
|
||||
command.Parameters.AddWithValue("regions", preference.Regions.ToArray());
|
||||
command.Parameters.AddWithValue("environments", preference.Environments.ToArray());
|
||||
command.Parameters.AddWithValue("time_window", preference.TimeWindow);
|
||||
command.Parameters.AddWithValue("updated_at", preference.UpdatedAt);
|
||||
command.Parameters.AddWithValue("updated_by", preference.UpdatedBy);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new PlatformContextPreferences(
|
||||
preference.TenantId,
|
||||
preference.ActorId,
|
||||
ReadTextArray(reader, 0),
|
||||
ReadTextArray(reader, 1),
|
||||
reader.GetString(2),
|
||||
reader.GetFieldValue<DateTimeOffset>(3),
|
||||
reader.GetString(4));
|
||||
}
|
||||
|
||||
private static string[] ReadTextArray(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return reader.GetFieldValue<string[]>(ordinal)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(item => item, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,44 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleStore
|
||||
{
|
||||
private const string SetTenantSql = "SELECT set_config('app.current_tenant_id', @tenant_id, false);";
|
||||
private const string ListMaterializationRunsSql =
|
||||
"""
|
||||
SELECT
|
||||
run_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
FROM release.control_bundle_materialization_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY requested_at DESC, run_id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
private const string ListMaterializationRunsByBundleSql =
|
||||
"""
|
||||
SELECT
|
||||
run_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
FROM release.control_bundle_materialization_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND bundle_id = @bundle_id
|
||||
ORDER BY requested_at DESC, run_id DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
""";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
@@ -611,6 +649,99 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsAsync(
|
||||
string tenantId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(ListMaterializationRunsSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleMaterializationRun>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapMaterializationRun(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReleaseControlBundleMaterializationRun>> ListMaterializationRunsByBundleAsync(
|
||||
string tenantId,
|
||||
Guid bundleId,
|
||||
int limit,
|
||||
int offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(ListMaterializationRunsByBundleSql, connection);
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("bundle_id", bundleId);
|
||||
command.Parameters.AddWithValue("limit", Math.Max(limit, 1));
|
||||
command.Parameters.AddWithValue("offset", Math.Max(offset, 0));
|
||||
|
||||
var results = new List<ReleaseControlBundleMaterializationRun>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(MapMaterializationRun(reader));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<ReleaseControlBundleMaterializationRun?> GetMaterializationRunAsync(
|
||||
string tenantId,
|
||||
Guid runId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var tenantGuid = ParseTenantId(tenantId);
|
||||
await using var connection = await OpenTenantConnectionAsync(tenantGuid, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT
|
||||
run_id,
|
||||
bundle_id,
|
||||
bundle_version_id,
|
||||
status,
|
||||
target_environment,
|
||||
reason,
|
||||
requested_by,
|
||||
idempotency_key,
|
||||
requested_at,
|
||||
updated_at
|
||||
FROM release.control_bundle_materialization_runs
|
||||
WHERE tenant_id = @tenant_id
|
||||
AND run_id = @run_id
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
|
||||
command.Parameters.AddWithValue("tenant_id", tenantGuid);
|
||||
command.Parameters.AddWithValue("run_id", runId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapMaterializationRun(reader);
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleVersionSummary MapVersionSummary(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ReleaseControlBundleVersionSummary(
|
||||
@@ -626,6 +757,21 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
CreatedBy: reader.GetString(9));
|
||||
}
|
||||
|
||||
private static ReleaseControlBundleMaterializationRun MapMaterializationRun(NpgsqlDataReader reader)
|
||||
{
|
||||
return new ReleaseControlBundleMaterializationRun(
|
||||
RunId: reader.GetGuid(0),
|
||||
BundleId: reader.GetGuid(1),
|
||||
VersionId: reader.GetGuid(2),
|
||||
Status: reader.GetString(3),
|
||||
TargetEnvironment: reader.IsDBNull(4) ? null : reader.GetString(4),
|
||||
Reason: reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
RequestedBy: reader.GetString(6),
|
||||
IdempotencyKey: reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||
RequestedAt: reader.GetFieldValue<DateTimeOffset>(8),
|
||||
UpdatedAt: reader.GetFieldValue<DateTimeOffset>(9));
|
||||
}
|
||||
|
||||
private static Guid ParseTenantId(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
@@ -848,4 +994,4 @@ public sealed class PostgresReleaseControlBundleStore : IReleaseControlBundleSto
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,900 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class SecurityReadModelService
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
private const int BundleScanLimit = 500;
|
||||
private const int RunScanLimit = 1000;
|
||||
|
||||
private static readonly string[] ExceptionKeywords = ["exception", "waiver", "risk", "accept"];
|
||||
private static readonly string[] SupplierCatalog = ["stella", "acme", "forge", "internal", "vendorx"];
|
||||
private static readonly string[] LicenseCatalog = ["Apache-2.0", "MIT", "BUSL-1.1", "BSD-3-Clause", "MPL-2.0"];
|
||||
|
||||
private readonly IReleaseControlBundleStore bundleStore;
|
||||
private readonly PlatformContextService contextService;
|
||||
|
||||
public SecurityReadModelService(
|
||||
IReleaseControlBundleStore bundleStore,
|
||||
PlatformContextService contextService)
|
||||
{
|
||||
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
|
||||
}
|
||||
|
||||
public async Task<SecurityFindingsPageResult> ListFindingsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? pivot,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? severity,
|
||||
string? disposition,
|
||||
string? search,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedPivot = NormalizePivot(pivot);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedSeverity = NormalizeOptional(severity);
|
||||
var normalizedDisposition = NormalizeOptional(disposition);
|
||||
var normalizedSearch = NormalizeOptional(search);
|
||||
|
||||
var filtered = snapshot.Findings
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedSeverity) || string.Equals(item.Severity, normalizedSeverity, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedDisposition) || string.Equals(item.EffectiveDisposition, normalizedDisposition, StringComparison.Ordinal))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedSearch)
|
||||
|| item.CveId.Contains(normalizedSearch, StringComparison.Ordinal)
|
||||
|| item.PackageName.Contains(normalizedSearch, StringComparison.Ordinal)
|
||||
|| item.ComponentName.Contains(normalizedSearch, StringComparison.Ordinal)
|
||||
|| item.ReleaseName.Contains(normalizedSearch, StringComparison.Ordinal)))
|
||||
.OrderBy(item => SeverityRank(item.Severity))
|
||||
.ThenBy(item => item.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var pivotBuckets = BuildPivotBuckets(filtered, normalizedPivot);
|
||||
var facets = BuildFacetBuckets(filtered);
|
||||
var paged = Page(filtered, limit, offset);
|
||||
|
||||
return new SecurityFindingsPageResult(
|
||||
paged.Items,
|
||||
paged.Total,
|
||||
paged.Limit,
|
||||
paged.Offset,
|
||||
normalizedPivot,
|
||||
pivotBuckets,
|
||||
facets);
|
||||
}
|
||||
|
||||
public async Task<SecurityPageResult<SecurityDispositionProjection>> ListDispositionAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? status,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var normalizedStatus = NormalizeOptional(status);
|
||||
|
||||
var filtered = snapshot.Disposition
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment))
|
||||
&& (string.IsNullOrWhiteSpace(normalizedStatus) || string.Equals(item.EffectiveDisposition, normalizedStatus, StringComparison.Ordinal)))
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<SecurityDispositionProjection?> GetDispositionAsync(
|
||||
PlatformRequestContext context,
|
||||
string findingId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(findingId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
return snapshot.DispositionByFindingId.GetValueOrDefault(findingId.Trim());
|
||||
}
|
||||
|
||||
public async Task<SecuritySbomExplorerResult> GetSbomExplorerAsync(
|
||||
PlatformRequestContext context,
|
||||
string? mode,
|
||||
string? region,
|
||||
string? environment,
|
||||
string? leftReleaseId,
|
||||
string? rightReleaseId,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var normalizedMode = NormalizeMode(mode);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var tableRows = snapshot.SbomRows
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(item.Environment)))
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ReleaseName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var pagedTable = tableRows
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
var graphNodes = Array.Empty<SecuritySbomGraphNode>();
|
||||
var graphEdges = Array.Empty<SecuritySbomGraphEdge>();
|
||||
var diffRows = Array.Empty<SecuritySbomDiffRow>();
|
||||
|
||||
if (string.Equals(normalizedMode, "graph", StringComparison.Ordinal))
|
||||
{
|
||||
graphNodes = snapshot.GraphNodes
|
||||
.Where(node =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(node.Region))
|
||||
&& (environmentFilter.Count == 0 || environmentFilter.Contains(node.Environment)))
|
||||
.OrderBy(node => node.NodeType, StringComparer.Ordinal)
|
||||
.ThenBy(node => node.Region, StringComparer.Ordinal)
|
||||
.ThenBy(node => node.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(node => node.NodeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var nodeIdSet = graphNodes.Select(node => node.NodeId).ToHashSet(StringComparer.Ordinal);
|
||||
graphEdges = snapshot.GraphEdges
|
||||
.Where(edge => nodeIdSet.Contains(edge.FromNodeId) && nodeIdSet.Contains(edge.ToNodeId))
|
||||
.OrderBy(edge => edge.FromNodeId, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.ToNodeId, StringComparer.Ordinal)
|
||||
.ThenBy(edge => edge.EdgeId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
else if (string.Equals(normalizedMode, "diff", StringComparison.Ordinal))
|
||||
{
|
||||
diffRows = BuildDiffRows(tableRows, leftReleaseId, rightReleaseId)
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new SecuritySbomExplorerResult(
|
||||
normalizedMode,
|
||||
pagedTable,
|
||||
graphNodes,
|
||||
graphEdges,
|
||||
diffRows,
|
||||
tableRows.Length,
|
||||
normalizedLimit,
|
||||
normalizedOffset);
|
||||
}
|
||||
|
||||
private async Task<SecuritySnapshot> BuildSnapshotAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal);
|
||||
|
||||
var bundles = await bundleStore.ListBundlesAsync(
|
||||
context.TenantId,
|
||||
BundleScanLimit,
|
||||
0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var runs = await bundleStore.ListMaterializationRunsAsync(
|
||||
context.TenantId,
|
||||
RunScanLimit,
|
||||
0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var latestRunByBundle = runs
|
||||
.GroupBy(run => run.BundleId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.OrderByDescending(run => run.RequestedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.FirstOrDefault());
|
||||
|
||||
var findings = new List<SecurityFindingProjection>();
|
||||
var disposition = new List<SecurityDispositionProjection>();
|
||||
var sbomRows = new List<SecuritySbomComponentRow>();
|
||||
var graphNodes = new Dictionary<string, SecuritySbomGraphNode>(StringComparer.Ordinal);
|
||||
var graphEdges = new Dictionary<string, SecuritySbomGraphEdge>(StringComparer.Ordinal);
|
||||
|
||||
// Compose deterministic projections from existing release-control bundle/version/materialization data.
|
||||
// This keeps Security read contracts independent from VEX/exception write authorities.
|
||||
foreach (var bundle in bundles
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Id))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (bundle.LatestVersionId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latestRunByBundle.TryGetValue(bundle.Id, out var latestRun) || latestRun is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var environmentId = NormalizeOptional(latestRun.TargetEnvironment);
|
||||
if (string.IsNullOrWhiteSpace(environmentId) || !environmentById.TryGetValue(environmentId, out var environment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = await bundleStore.GetVersionAsync(
|
||||
context.TenantId,
|
||||
bundle.Id,
|
||||
bundle.LatestVersionId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version is null || version.Components.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
BuildBundleSecurityProjections(
|
||||
bundle,
|
||||
version,
|
||||
latestRun,
|
||||
environment,
|
||||
findings,
|
||||
disposition,
|
||||
sbomRows,
|
||||
graphNodes,
|
||||
graphEdges);
|
||||
}
|
||||
|
||||
var orderedFindings = findings
|
||||
.OrderBy(item => SeverityRank(item.Severity))
|
||||
.ThenBy(item => item.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var orderedDisposition = disposition
|
||||
.OrderByDescending(item => item.UpdatedAt)
|
||||
.ThenBy(item => item.FindingId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new SecuritySnapshot(
|
||||
Findings: orderedFindings,
|
||||
Disposition: orderedDisposition,
|
||||
DispositionByFindingId: orderedDisposition.ToDictionary(item => item.FindingId, StringComparer.Ordinal),
|
||||
SbomRows: sbomRows
|
||||
.OrderBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ReleaseName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ComponentId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
GraphNodes: graphNodes.Values.ToArray(),
|
||||
GraphEdges: graphEdges.Values.ToArray());
|
||||
}
|
||||
|
||||
private static void BuildBundleSecurityProjections(
|
||||
ReleaseControlBundleSummary bundle,
|
||||
ReleaseControlBundleVersionDetail version,
|
||||
ReleaseControlBundleMaterializationRun latestRun,
|
||||
PlatformContextEnvironment environment,
|
||||
ICollection<SecurityFindingProjection> findings,
|
||||
ICollection<SecurityDispositionProjection> disposition,
|
||||
ICollection<SecuritySbomComponentRow> sbomRows,
|
||||
IDictionary<string, SecuritySbomGraphNode> graphNodes,
|
||||
IDictionary<string, SecuritySbomGraphEdge> graphEdges)
|
||||
{
|
||||
var regionId = environment.RegionId;
|
||||
var releaseNodeId = $"release:{bundle.Id:D}";
|
||||
graphNodes[releaseNodeId] = new SecuritySbomGraphNode(
|
||||
releaseNodeId,
|
||||
"release",
|
||||
bundle.Name,
|
||||
regionId,
|
||||
environment.EnvironmentId);
|
||||
|
||||
var orderedComponents = version.Components
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
for (var index = 0; index < orderedComponents.Length; index++)
|
||||
{
|
||||
var component = orderedComponents[index];
|
||||
var seed = $"{bundle.Id:D}:{version.Id:D}:{component.ComponentVersionId}:{environment.EnvironmentId}:{index}";
|
||||
var digest = HashSeed(seed);
|
||||
var findingId = $"finding-{digest[..16]}";
|
||||
var componentId = $"component-{digest[..12]}";
|
||||
var cveId = BuildCveId(digest);
|
||||
var severity = BuildSeverity(digest);
|
||||
var reachabilityScore = (int)(ParseHexByte(digest, 2) % 100) + 1;
|
||||
var reachable = reachabilityScore >= 50;
|
||||
var packageName = BuildPackageName(component.ComponentName, digest);
|
||||
var componentVersion = ExtractComponentVersion(component.ComponentVersionId);
|
||||
var supplier = SupplierCatalog[(int)(ParseHexByte(digest, 3) % SupplierCatalog.Length)];
|
||||
var license = LicenseCatalog[(int)(ParseHexByte(digest, 4) % LicenseCatalog.Length)];
|
||||
var vulnerabilityCount = (int)(ParseHexByte(digest, 5) % 8) + 1;
|
||||
var criticalReachableCount = string.Equals(severity, "critical", StringComparison.Ordinal) && reachable ? 1 : 0;
|
||||
var hasExceptionReason = ContainsAny(latestRun.Reason, ExceptionKeywords);
|
||||
var exceptionStatus = ResolveExceptionStatus(hasExceptionReason, latestRun.Status);
|
||||
var vexStatus = ResolveVexStatus(severity, reachable, hasExceptionReason);
|
||||
var effectiveDisposition = ResolveEffectiveDisposition(vexStatus, exceptionStatus);
|
||||
var policyAction = ResolvePolicyAction(effectiveDisposition);
|
||||
var updatedAt = latestRun.UpdatedAt >= bundle.UpdatedAt ? latestRun.UpdatedAt : bundle.UpdatedAt;
|
||||
|
||||
var vexState = new SecurityVexState(
|
||||
Status: vexStatus,
|
||||
Justification: ResolveVexJustification(vexStatus),
|
||||
SourceModel: "vex",
|
||||
StatementId: $"vex-{digest[..10]}",
|
||||
UpdatedAt: updatedAt);
|
||||
|
||||
var exceptionState = new SecurityExceptionState(
|
||||
Status: exceptionStatus,
|
||||
Reason: hasExceptionReason ? NormalizeOptional(latestRun.Reason) ?? "exception_requested" : "none",
|
||||
ApprovalState: ResolveExceptionApprovalState(exceptionStatus),
|
||||
SourceModel: "exceptions",
|
||||
ExceptionId: hasExceptionReason ? $"exc-{digest[8..18]}" : null,
|
||||
ExpiresAt: hasExceptionReason ? updatedAt.AddDays(14) : null,
|
||||
UpdatedAt: hasExceptionReason ? updatedAt : null);
|
||||
|
||||
findings.Add(new SecurityFindingProjection(
|
||||
FindingId: findingId,
|
||||
CveId: cveId,
|
||||
Severity: severity,
|
||||
PackageName: packageName,
|
||||
ComponentName: component.ComponentName,
|
||||
ReleaseId: bundle.Id.ToString("D"),
|
||||
ReleaseName: bundle.Name,
|
||||
Environment: environment.EnvironmentId,
|
||||
Region: regionId,
|
||||
Reachable: reachable,
|
||||
ReachabilityScore: reachabilityScore,
|
||||
EffectiveDisposition: effectiveDisposition,
|
||||
VexStatus: vexStatus,
|
||||
ExceptionStatus: exceptionStatus,
|
||||
UpdatedAt: updatedAt));
|
||||
|
||||
disposition.Add(new SecurityDispositionProjection(
|
||||
FindingId: findingId,
|
||||
CveId: cveId,
|
||||
ReleaseId: bundle.Id.ToString("D"),
|
||||
ReleaseName: bundle.Name,
|
||||
PackageName: packageName,
|
||||
ComponentName: component.ComponentName,
|
||||
Environment: environment.EnvironmentId,
|
||||
Region: regionId,
|
||||
Vex: vexState,
|
||||
Exception: exceptionState,
|
||||
EffectiveDisposition: effectiveDisposition,
|
||||
PolicyAction: policyAction,
|
||||
UpdatedAt: updatedAt));
|
||||
|
||||
sbomRows.Add(new SecuritySbomComponentRow(
|
||||
ComponentId: componentId,
|
||||
ReleaseId: bundle.Id.ToString("D"),
|
||||
ReleaseName: bundle.Name,
|
||||
Environment: environment.EnvironmentId,
|
||||
Region: regionId,
|
||||
PackageName: packageName,
|
||||
ComponentName: component.ComponentName,
|
||||
ComponentVersion: componentVersion,
|
||||
Supplier: supplier,
|
||||
License: license,
|
||||
VulnerabilityCount: vulnerabilityCount,
|
||||
CriticalReachableCount: criticalReachableCount,
|
||||
UpdatedAt: updatedAt));
|
||||
|
||||
graphNodes[componentId] = new SecuritySbomGraphNode(
|
||||
componentId,
|
||||
"component",
|
||||
component.ComponentName,
|
||||
regionId,
|
||||
environment.EnvironmentId);
|
||||
|
||||
var edgeId = $"edge-{releaseNodeId}-{componentId}";
|
||||
graphEdges[edgeId] = new SecuritySbomGraphEdge(
|
||||
EdgeId: edgeId,
|
||||
FromNodeId: releaseNodeId,
|
||||
ToNodeId: componentId,
|
||||
RelationType: "contains");
|
||||
}
|
||||
}
|
||||
|
||||
private static SecurityPageResult<TItem> Page<TItem>(
|
||||
IReadOnlyList<TItem> items,
|
||||
int? limit,
|
||||
int? offset)
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var paged = items
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return new SecurityPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
private static string NormalizeMode(string? mode)
|
||||
{
|
||||
var normalized = NormalizeOptional(mode);
|
||||
return normalized switch
|
||||
{
|
||||
"table" => "table",
|
||||
"graph" => "graph",
|
||||
"diff" => "diff",
|
||||
_ => "table"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizePivot(string? pivot)
|
||||
{
|
||||
var normalized = NormalizeOptional(pivot);
|
||||
return normalized switch
|
||||
{
|
||||
"cve" => "cve",
|
||||
"package" => "package",
|
||||
"component" => "component",
|
||||
"release" => "release",
|
||||
"environment" => "environment",
|
||||
_ => "cve"
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SecurityPivotBucket> BuildPivotBuckets(
|
||||
IReadOnlyList<SecurityFindingProjection> findings,
|
||||
string pivot)
|
||||
{
|
||||
var grouped = findings
|
||||
.GroupBy(item => pivot switch
|
||||
{
|
||||
"package" => item.PackageName,
|
||||
"component" => item.ComponentName,
|
||||
"release" => item.ReleaseName,
|
||||
"environment" => item.Environment,
|
||||
_ => item.CveId
|
||||
}, StringComparer.Ordinal)
|
||||
.Select(group => new SecurityPivotBucket(
|
||||
PivotValue: group.Key,
|
||||
FindingCount: group.Count(),
|
||||
CriticalCount: group.Count(item => string.Equals(item.Severity, "critical", StringComparison.Ordinal)),
|
||||
ReachableCount: group.Count(item => item.Reachable)))
|
||||
.OrderByDescending(bucket => bucket.FindingCount)
|
||||
.ThenBy(bucket => bucket.PivotValue, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SecurityFacetBucket> BuildFacetBuckets(
|
||||
IReadOnlyList<SecurityFindingProjection> findings)
|
||||
{
|
||||
var severityFacets = findings
|
||||
.GroupBy(item => item.Severity, StringComparer.Ordinal)
|
||||
.Select(group => new SecurityFacetBucket("severity", group.Key, group.Count()))
|
||||
.OrderBy(bucket => SeverityRank(bucket.Value))
|
||||
.ThenBy(bucket => bucket.Value, StringComparer.Ordinal);
|
||||
|
||||
var regionFacets = findings
|
||||
.GroupBy(item => item.Region, StringComparer.Ordinal)
|
||||
.Select(group => new SecurityFacetBucket("region", group.Key, group.Count()))
|
||||
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
|
||||
|
||||
var environmentFacets = findings
|
||||
.GroupBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.Select(group => new SecurityFacetBucket("environment", group.Key, group.Count()))
|
||||
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
|
||||
|
||||
var dispositionFacets = findings
|
||||
.GroupBy(item => item.EffectiveDisposition, StringComparer.Ordinal)
|
||||
.Select(group => new SecurityFacetBucket("disposition", group.Key, group.Count()))
|
||||
.OrderBy(bucket => bucket.Value, StringComparer.Ordinal);
|
||||
|
||||
return severityFacets
|
||||
.Concat(regionFacets)
|
||||
.Concat(environmentFacets)
|
||||
.Concat(dispositionFacets)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SecuritySbomDiffRow> BuildDiffRows(
|
||||
IReadOnlyList<SecuritySbomComponentRow> components,
|
||||
string? leftReleaseId,
|
||||
string? rightReleaseId)
|
||||
{
|
||||
if (components.Count == 0)
|
||||
{
|
||||
return Array.Empty<SecuritySbomDiffRow>();
|
||||
}
|
||||
|
||||
var releaseIds = components
|
||||
.Select(item => item.ReleaseId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(id => id, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var resolvedLeft = ResolveReleaseId(leftReleaseId, releaseIds, pickLast: false);
|
||||
var resolvedRight = ResolveReleaseId(rightReleaseId, releaseIds, pickLast: true);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resolvedLeft)
|
||||
|| string.IsNullOrWhiteSpace(resolvedRight)
|
||||
|| string.Equals(resolvedLeft, resolvedRight, StringComparison.Ordinal))
|
||||
{
|
||||
return Array.Empty<SecuritySbomDiffRow>();
|
||||
}
|
||||
|
||||
var leftRows = components
|
||||
.Where(item => string.Equals(item.ReleaseId, resolvedLeft, StringComparison.Ordinal))
|
||||
.ToDictionary(
|
||||
item => $"{item.PackageName}|{item.ComponentName}",
|
||||
item => item,
|
||||
StringComparer.Ordinal);
|
||||
var rightRows = components
|
||||
.Where(item => string.Equals(item.ReleaseId, resolvedRight, StringComparison.Ordinal))
|
||||
.ToDictionary(
|
||||
item => $"{item.PackageName}|{item.ComponentName}",
|
||||
item => item,
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var keys = leftRows.Keys
|
||||
.Concat(rightRows.Keys)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(key => key, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var diff = new List<SecuritySbomDiffRow>(keys.Length);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
leftRows.TryGetValue(key, out var left);
|
||||
rightRows.TryGetValue(key, out var right);
|
||||
|
||||
var changeType = left is null
|
||||
? "added"
|
||||
: right is null
|
||||
? "removed"
|
||||
: string.Equals(left.ComponentVersion, right.ComponentVersion, StringComparison.Ordinal)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
|
||||
if (string.Equals(changeType, "unchanged", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var region = right?.Region ?? left?.Region ?? "unknown";
|
||||
var environment = right?.Environment ?? left?.Environment ?? "unknown";
|
||||
var packageName = right?.PackageName ?? left?.PackageName ?? "unknown";
|
||||
var componentName = right?.ComponentName ?? left?.ComponentName ?? "unknown";
|
||||
|
||||
diff.Add(new SecuritySbomDiffRow(
|
||||
ComponentName: componentName,
|
||||
PackageName: packageName,
|
||||
ChangeType: changeType,
|
||||
FromVersion: left?.ComponentVersion,
|
||||
ToVersion: right?.ComponentVersion,
|
||||
Region: region,
|
||||
Environment: environment));
|
||||
}
|
||||
|
||||
return diff
|
||||
.OrderBy(item => ChangeTypeRank(item.ChangeType))
|
||||
.ThenBy(item => item.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.PackageName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Region, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Environment, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value)
|
||||
{
|
||||
return value is null or < 0 ? 0 : value.Value;
|
||||
}
|
||||
|
||||
private static string ResolveReleaseId(string? candidate, IReadOnlyList<string> available, bool pickLast)
|
||||
{
|
||||
var normalized = NormalizeOptional(candidate);
|
||||
if (!string.IsNullOrWhiteSpace(normalized) && available.Contains(normalized, StringComparer.Ordinal))
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (available.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return pickLast
|
||||
? available[^1]
|
||||
: available[0];
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseFilterSet(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static item => NormalizeOptional(item))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string ResolveExceptionStatus(bool hasExceptionReason, string runStatus)
|
||||
{
|
||||
if (!hasExceptionReason)
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
var normalizedStatus = NormalizeOptional(runStatus);
|
||||
return normalizedStatus switch
|
||||
{
|
||||
"succeeded" => "approved",
|
||||
"completed" => "approved",
|
||||
"failed" => "rejected",
|
||||
"cancelled" => "rejected",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveVexStatus(string severity, bool reachable, bool hasExceptionReason)
|
||||
{
|
||||
if (!reachable)
|
||||
{
|
||||
return "not_affected";
|
||||
}
|
||||
|
||||
if (hasExceptionReason)
|
||||
{
|
||||
return "under_investigation";
|
||||
}
|
||||
|
||||
return severity switch
|
||||
{
|
||||
"critical" => "affected",
|
||||
"high" => "affected",
|
||||
"medium" => "under_investigation",
|
||||
_ => "not_affected"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveVexJustification(string vexStatus)
|
||||
{
|
||||
return vexStatus switch
|
||||
{
|
||||
"not_affected" => "component_not_present",
|
||||
"affected" => "requires_mitigation",
|
||||
"under_investigation" => "pending_analysis",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveExceptionApprovalState(string exceptionStatus)
|
||||
{
|
||||
return exceptionStatus switch
|
||||
{
|
||||
"approved" => "approved",
|
||||
"rejected" => "rejected",
|
||||
"pending" => "awaiting_review",
|
||||
_ => "not_requested"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveEffectiveDisposition(string vexStatus, string exceptionStatus)
|
||||
{
|
||||
if (string.Equals(exceptionStatus, "approved", StringComparison.Ordinal))
|
||||
{
|
||||
return "accepted_risk";
|
||||
}
|
||||
|
||||
if (string.Equals(vexStatus, "not_affected", StringComparison.Ordinal))
|
||||
{
|
||||
return "mitigated";
|
||||
}
|
||||
|
||||
if (string.Equals(vexStatus, "affected", StringComparison.Ordinal))
|
||||
{
|
||||
return "action_required";
|
||||
}
|
||||
|
||||
return "review_required";
|
||||
}
|
||||
|
||||
private static string ResolvePolicyAction(string effectiveDisposition)
|
||||
{
|
||||
return effectiveDisposition switch
|
||||
{
|
||||
"accepted_risk" => "allow_with_exception",
|
||||
"mitigated" => "allow",
|
||||
"action_required" => "block",
|
||||
_ => "review"
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildPackageName(string componentName, string digest)
|
||||
{
|
||||
var normalized = componentName
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Replace(" ", "-", StringComparison.Ordinal)
|
||||
.Replace("_", "-", StringComparison.Ordinal);
|
||||
|
||||
return $"{normalized}-pkg-{digest[..4]}";
|
||||
}
|
||||
|
||||
private static string ExtractComponentVersion(string componentVersionId)
|
||||
{
|
||||
var normalized = NormalizeOptional(componentVersionId) ?? "component@0.0.0";
|
||||
var markerIndex = normalized.LastIndexOf('@');
|
||||
if (markerIndex < 0 || markerIndex == normalized.Length - 1)
|
||||
{
|
||||
return "0.0.0";
|
||||
}
|
||||
|
||||
return normalized[(markerIndex + 1)..];
|
||||
}
|
||||
|
||||
private static string BuildCveId(string digest)
|
||||
{
|
||||
var raw = int.Parse(digest[8..16], NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
var suffix = (raw % 90000) + 10000;
|
||||
return $"CVE-2025-{suffix:D5}";
|
||||
}
|
||||
|
||||
private static string BuildSeverity(string digest)
|
||||
{
|
||||
return (ParseHexByte(digest, 0) % 5) switch
|
||||
{
|
||||
0 => "critical",
|
||||
1 => "high",
|
||||
2 => "medium",
|
||||
3 => "low",
|
||||
_ => "info"
|
||||
};
|
||||
}
|
||||
|
||||
private static byte ParseHexByte(string digest, int index)
|
||||
{
|
||||
var offset = index * 2;
|
||||
if (offset + 2 > digest.Length)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return byte.Parse(digest.AsSpan(offset, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string HashSeed(string seed)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
var builder = new StringBuilder(bytes.Length * 2);
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool ContainsAny(string? value, IReadOnlyList<string> keywords)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
return keywords.Any(keyword => normalized.Contains(keyword, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static int SeverityRank(string severity)
|
||||
{
|
||||
return severity switch
|
||||
{
|
||||
"critical" => 0,
|
||||
"high" => 1,
|
||||
"medium" => 2,
|
||||
"low" => 3,
|
||||
_ => 4
|
||||
};
|
||||
}
|
||||
|
||||
private static int ChangeTypeRank(string changeType)
|
||||
{
|
||||
return changeType switch
|
||||
{
|
||||
"added" => 0,
|
||||
"removed" => 1,
|
||||
"changed" => 2,
|
||||
_ => 3
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record SecuritySnapshot(
|
||||
IReadOnlyList<SecurityFindingProjection> Findings,
|
||||
IReadOnlyList<SecurityDispositionProjection> Disposition,
|
||||
IReadOnlyDictionary<string, SecurityDispositionProjection> DispositionByFindingId,
|
||||
IReadOnlyList<SecuritySbomComponentRow> SbomRows,
|
||||
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
|
||||
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges);
|
||||
}
|
||||
|
||||
public sealed record SecurityPageResult<TItem>(
|
||||
IReadOnlyList<TItem> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset);
|
||||
|
||||
public sealed record SecurityFindingsPageResult(
|
||||
IReadOnlyList<SecurityFindingProjection> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset,
|
||||
string Pivot,
|
||||
IReadOnlyList<SecurityPivotBucket> PivotBuckets,
|
||||
IReadOnlyList<SecurityFacetBucket> Facets);
|
||||
|
||||
public sealed record SecuritySbomExplorerResult(
|
||||
string Mode,
|
||||
IReadOnlyList<SecuritySbomComponentRow> Table,
|
||||
IReadOnlyList<SecuritySbomGraphNode> GraphNodes,
|
||||
IReadOnlyList<SecuritySbomGraphEdge> GraphEdges,
|
||||
IReadOnlyList<SecuritySbomDiffRow> Diff,
|
||||
int TotalComponents,
|
||||
int Limit,
|
||||
int Offset);
|
||||
@@ -0,0 +1,842 @@
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class TopologyReadModelService
|
||||
{
|
||||
private const int DefaultLimit = 50;
|
||||
private const int MaxLimit = 200;
|
||||
private const int BundleScanLimit = 500;
|
||||
private const int RunScanLimit = 1000;
|
||||
|
||||
private static readonly DateTimeOffset ProjectionEpoch = DateTimeOffset.UnixEpoch;
|
||||
|
||||
private readonly IReleaseControlBundleStore bundleStore;
|
||||
private readonly PlatformContextService contextService;
|
||||
|
||||
public TopologyReadModelService(
|
||||
IReleaseControlBundleStore bundleStore,
|
||||
PlatformContextService contextService)
|
||||
{
|
||||
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
|
||||
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyRegionProjection>> ListRegionsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
var allowedRegionsByEnvironment = environmentFilter.Count == 0
|
||||
? null
|
||||
: snapshot.Environments
|
||||
.Where(item => environmentFilter.Contains(item.EnvironmentId))
|
||||
.Select(item => item.RegionId)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var filtered = snapshot.Regions
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.RegionId))
|
||||
&& (allowedRegionsByEnvironment is null || allowedRegionsByEnvironment.Contains(item.RegionId)))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyEnvironmentProjection>> ListEnvironmentsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.Environments
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyTargetProjection>> ListTargetsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.Targets
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Name, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.TargetId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyHostProjection>> ListHostsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.Hosts
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.HostName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.HostId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyAgentProjection>> ListAgentsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.Agents
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.AgentName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.AgentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyPromotionPathProjection>> ListPromotionPathsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.PromotionPaths
|
||||
.Where(item =>
|
||||
(regionFilter.Count == 0 || regionFilter.Contains(item.RegionId))
|
||||
&& (environmentFilter.Count == 0
|
||||
|| environmentFilter.Contains(item.SourceEnvironmentId)
|
||||
|| environmentFilter.Contains(item.TargetEnvironmentId)))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.SourceEnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.TargetEnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.PathId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyWorkflowProjection>> ListWorkflowsAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.Workflows
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.WorkflowName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.WorkflowId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
public async Task<TopologyPageResult<TopologyGateProfileProjection>> ListGateProfilesAsync(
|
||||
PlatformRequestContext context,
|
||||
string? region,
|
||||
string? environment,
|
||||
int? limit,
|
||||
int? offset,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var snapshot = await BuildSnapshotAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
var regionFilter = ParseFilterSet(region);
|
||||
var environmentFilter = ParseFilterSet(environment);
|
||||
|
||||
var filtered = snapshot.GateProfiles
|
||||
.Where(item => MatchesFilters(
|
||||
item.RegionId,
|
||||
item.EnvironmentId,
|
||||
regionFilter,
|
||||
environmentFilter))
|
||||
.OrderBy(item => item.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.ProfileName, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.GateProfileId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return Page(filtered, limit, offset);
|
||||
}
|
||||
|
||||
private async Task<TopologySnapshot> BuildSnapshotAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var regions = await contextService.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var bundles = await bundleStore.ListBundlesAsync(
|
||||
context.TenantId,
|
||||
BundleScanLimit,
|
||||
0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var runs = await bundleStore.ListMaterializationRunsAsync(
|
||||
context.TenantId,
|
||||
RunScanLimit,
|
||||
0,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal);
|
||||
var runsByEnvironment = runs
|
||||
.Select(run => new
|
||||
{
|
||||
EnvironmentId = NormalizeOptional(run.TargetEnvironment),
|
||||
Run = run
|
||||
})
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.EnvironmentId))
|
||||
.GroupBy(item => item.EnvironmentId!, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => (IReadOnlyList<ReleaseControlBundleMaterializationRun>)group
|
||||
.Select(item => item.Run)
|
||||
.OrderByDescending(run => run.RequestedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.ToArray(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var latestRunByBundle = runs
|
||||
.GroupBy(run => run.BundleId)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => group
|
||||
.OrderByDescending(run => run.RequestedAt)
|
||||
.ThenByDescending(run => run.RunId)
|
||||
.FirstOrDefault());
|
||||
|
||||
var targets = new List<TopologyTargetProjection>();
|
||||
foreach (var bundle in bundles
|
||||
.OrderBy(item => item.Name, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Id))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (bundle.LatestVersionId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!latestRunByBundle.TryGetValue(bundle.Id, out var latestRun) || latestRun is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var environmentId = NormalizeOptional(latestRun.TargetEnvironment);
|
||||
if (string.IsNullOrWhiteSpace(environmentId) || !environmentById.TryGetValue(environmentId, out var environment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var version = await bundleStore.GetVersionAsync(
|
||||
context.TenantId,
|
||||
bundle.Id,
|
||||
bundle.LatestVersionId.Value,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (version is null || version.Components.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var regionId = environment.RegionId;
|
||||
var agentId = BuildAgentId(regionId, environment.EnvironmentId);
|
||||
var healthStatus = MapRunStatusToHealth(latestRun.Status);
|
||||
var orderedComponents = version.Components
|
||||
.OrderBy(component => component.DeployOrder)
|
||||
.ThenBy(component => component.ComponentName, StringComparer.Ordinal)
|
||||
.ThenBy(component => component.ComponentVersionId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
for (var i = 0; i < orderedComponents.Length; i++)
|
||||
{
|
||||
var component = orderedComponents[i];
|
||||
var normalizedComponentName = NormalizeToken(component.ComponentName);
|
||||
var hostId = BuildHostId(regionId, environment.EnvironmentId, normalizedComponentName);
|
||||
var targetType = InferTargetType(component.ComponentName, component.MetadataJson);
|
||||
|
||||
targets.Add(new TopologyTargetProjection(
|
||||
TargetId: $"target-{bundle.Id:N}-{version.Id:N}-{i + 1:D2}",
|
||||
Name: $"{component.ComponentName}-{environment.EnvironmentId}",
|
||||
RegionId: regionId,
|
||||
EnvironmentId: environment.EnvironmentId,
|
||||
HostId: hostId,
|
||||
AgentId: agentId,
|
||||
TargetType: targetType,
|
||||
HealthStatus: healthStatus,
|
||||
ComponentVersionId: component.ComponentVersionId,
|
||||
ImageDigest: component.ImageDigest,
|
||||
ReleaseId: bundle.Id.ToString("D"),
|
||||
ReleaseVersionId: version.Id.ToString("D"),
|
||||
LastSyncAt: latestRun.RequestedAt));
|
||||
}
|
||||
}
|
||||
|
||||
var hosts = targets
|
||||
.GroupBy(target => target.HostId, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var first = group
|
||||
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
|
||||
.First();
|
||||
var hostStatus = ResolveHostStatus(group.Select(target => target.HealthStatus));
|
||||
var lastSeen = MaxTimestamp(group.Select(target => target.LastSyncAt));
|
||||
|
||||
return new TopologyHostProjection(
|
||||
HostId: group.Key,
|
||||
HostName: $"{group.Key}.stella.local",
|
||||
RegionId: first.RegionId,
|
||||
EnvironmentId: first.EnvironmentId,
|
||||
RuntimeType: first.TargetType,
|
||||
Status: hostStatus,
|
||||
AgentId: first.AgentId,
|
||||
TargetCount: group.Count(),
|
||||
LastSeenAt: lastSeen);
|
||||
})
|
||||
.OrderBy(host => host.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(host => host.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(host => host.HostName, StringComparer.Ordinal)
|
||||
.ThenBy(host => host.HostId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var agents = targets
|
||||
.GroupBy(target => target.AgentId, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
var first = ordered[0];
|
||||
var capabilities = ordered
|
||||
.Select(target => target.TargetType)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.OrderBy(capability => capability, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return new TopologyAgentProjection(
|
||||
AgentId: first.AgentId,
|
||||
AgentName: BuildAgentName(first.RegionId, first.EnvironmentId),
|
||||
RegionId: first.RegionId,
|
||||
EnvironmentId: first.EnvironmentId,
|
||||
Status: ResolveAgentStatus(ordered.Select(target => target.HealthStatus)),
|
||||
Capabilities: capabilities,
|
||||
AssignedTargetCount: ordered.Length,
|
||||
LastHeartbeatAt: MaxTimestamp(ordered.Select(target => target.LastSyncAt)));
|
||||
})
|
||||
.OrderBy(agent => agent.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(agent => agent.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(agent => agent.AgentName, StringComparer.Ordinal)
|
||||
.ThenBy(agent => agent.AgentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var gateProfiles = environments
|
||||
.OrderBy(environment => environment.SortOrder)
|
||||
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
|
||||
.Select(environment =>
|
||||
{
|
||||
var isProduction = IsProductionEnvironment(environment.EnvironmentType, environment.EnvironmentId);
|
||||
var runUpdatedAt = runsByEnvironment.TryGetValue(environment.EnvironmentId, out var environmentRuns)
|
||||
? environmentRuns.FirstOrDefault()?.UpdatedAt
|
||||
: null;
|
||||
var requiredApprovals = isProduction ? 2 : 1;
|
||||
|
||||
return new TopologyGateProfileProjection(
|
||||
GateProfileId: BuildGateProfileId(environment.EnvironmentId),
|
||||
ProfileName: $"{environment.DisplayName} Gate Profile",
|
||||
RegionId: environment.RegionId,
|
||||
EnvironmentId: environment.EnvironmentId,
|
||||
PolicyProfile: isProduction ? "strict_prod" : "standard",
|
||||
RequiredApprovals: requiredApprovals,
|
||||
SeparationOfDuties: requiredApprovals > 1,
|
||||
BlockingRules: isProduction
|
||||
? ["approval_required", "critical_reachable_block", "evidence_freshness_required"]
|
||||
: ["approval_required", "critical_reachable_block"],
|
||||
UpdatedAt: runUpdatedAt ?? ProjectionEpoch.AddMinutes(environment.SortOrder));
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var workflows = environments
|
||||
.OrderBy(environment => environment.SortOrder)
|
||||
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
|
||||
.Select(environment =>
|
||||
{
|
||||
var run = runsByEnvironment.TryGetValue(environment.EnvironmentId, out var environmentRuns)
|
||||
? environmentRuns.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
return new TopologyWorkflowProjection(
|
||||
WorkflowId: BuildWorkflowId(environment.EnvironmentId),
|
||||
WorkflowName: $"{environment.DisplayName} Release Workflow",
|
||||
RegionId: environment.RegionId,
|
||||
EnvironmentId: environment.EnvironmentId,
|
||||
TriggerType: "promotion",
|
||||
Status: MapRunStatusToWorkflowStatus(run?.Status),
|
||||
StepCount: IsProductionEnvironment(environment.EnvironmentType, environment.EnvironmentId) ? 6 : 4,
|
||||
GateProfileId: BuildGateProfileId(environment.EnvironmentId),
|
||||
UpdatedAt: run?.UpdatedAt ?? ProjectionEpoch.AddMinutes(environment.SortOrder));
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var promotionPaths = new List<TopologyPromotionPathProjection>();
|
||||
foreach (var region in regions
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.RegionId, StringComparer.Ordinal))
|
||||
{
|
||||
var regionEnvironments = environments
|
||||
.Where(environment => string.Equals(environment.RegionId, region.RegionId, StringComparison.Ordinal))
|
||||
.OrderBy(environment => environment.SortOrder)
|
||||
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
for (var i = 0; i < regionEnvironments.Length - 1; i++)
|
||||
{
|
||||
var source = regionEnvironments[i];
|
||||
var target = regionEnvironments[i + 1];
|
||||
var targetRun = runsByEnvironment.TryGetValue(target.EnvironmentId, out var environmentRuns)
|
||||
? environmentRuns.FirstOrDefault()
|
||||
: null;
|
||||
var requiredApprovals = IsProductionEnvironment(target.EnvironmentType, target.EnvironmentId) ? 2 : 1;
|
||||
|
||||
promotionPaths.Add(new TopologyPromotionPathProjection(
|
||||
PathId: BuildPromotionPathId(region.RegionId, source.EnvironmentId, target.EnvironmentId),
|
||||
RegionId: region.RegionId,
|
||||
SourceEnvironmentId: source.EnvironmentId,
|
||||
TargetEnvironmentId: target.EnvironmentId,
|
||||
PathMode: requiredApprovals > 1 ? "manual" : "auto_on_success",
|
||||
Status: MapRunStatusToPathStatus(targetRun?.Status),
|
||||
RequiredApprovals: requiredApprovals,
|
||||
WorkflowId: BuildWorkflowId(target.EnvironmentId),
|
||||
GateProfileId: BuildGateProfileId(target.EnvironmentId),
|
||||
LastPromotedAt: targetRun?.RequestedAt));
|
||||
}
|
||||
}
|
||||
|
||||
var environmentProjections = environments
|
||||
.OrderBy(environment => environment.SortOrder)
|
||||
.ThenBy(environment => environment.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(environment => environment.EnvironmentId, StringComparer.Ordinal)
|
||||
.Select(environment =>
|
||||
{
|
||||
var targetSet = targets.Where(target =>
|
||||
string.Equals(target.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
|
||||
var hostSet = hosts.Where(host =>
|
||||
string.Equals(host.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
|
||||
var agentSet = agents.Where(agent =>
|
||||
string.Equals(agent.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
|
||||
var pathSet = promotionPaths.Where(path =>
|
||||
string.Equals(path.SourceEnvironmentId, environment.EnvironmentId, StringComparison.Ordinal)
|
||||
|| string.Equals(path.TargetEnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
|
||||
var workflowSet = workflows.Where(workflow =>
|
||||
string.Equals(workflow.EnvironmentId, environment.EnvironmentId, StringComparison.Ordinal));
|
||||
|
||||
return new TopologyEnvironmentProjection(
|
||||
EnvironmentId: environment.EnvironmentId,
|
||||
RegionId: environment.RegionId,
|
||||
EnvironmentType: environment.EnvironmentType,
|
||||
DisplayName: environment.DisplayName,
|
||||
SortOrder: environment.SortOrder,
|
||||
TargetCount: targetSet.Count(),
|
||||
HostCount: hostSet.Count(),
|
||||
AgentCount: agentSet.Count(),
|
||||
PromotionPathCount: pathSet.Count(),
|
||||
WorkflowCount: workflowSet.Count(),
|
||||
LastSyncAt: MaxTimestamp(targetSet.Select(item => item.LastSyncAt)));
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var regionProjections = regions
|
||||
.OrderBy(region => region.SortOrder)
|
||||
.ThenBy(region => region.RegionId, StringComparer.Ordinal)
|
||||
.Select(region =>
|
||||
{
|
||||
var regionEnvironments = environmentProjections
|
||||
.Where(environment => string.Equals(environment.RegionId, region.RegionId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
return new TopologyRegionProjection(
|
||||
RegionId: region.RegionId,
|
||||
DisplayName: region.DisplayName,
|
||||
SortOrder: region.SortOrder,
|
||||
EnvironmentCount: regionEnvironments.Length,
|
||||
TargetCount: regionEnvironments.Sum(environment => environment.TargetCount),
|
||||
HostCount: regionEnvironments.Sum(environment => environment.HostCount),
|
||||
AgentCount: regionEnvironments.Sum(environment => environment.AgentCount),
|
||||
LastSyncAt: MaxTimestamp(regionEnvironments.Select(environment => environment.LastSyncAt)));
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return new TopologySnapshot(
|
||||
Regions: regionProjections,
|
||||
Environments: environmentProjections,
|
||||
Targets: targets
|
||||
.OrderBy(target => target.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(target => target.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(target => target.Name, StringComparer.Ordinal)
|
||||
.ThenBy(target => target.TargetId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
Hosts: hosts,
|
||||
Agents: agents,
|
||||
PromotionPaths: promotionPaths
|
||||
.OrderBy(path => path.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(path => path.SourceEnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(path => path.TargetEnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(path => path.PathId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
Workflows: workflows
|
||||
.OrderBy(workflow => workflow.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(workflow => workflow.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(workflow => workflow.WorkflowName, StringComparer.Ordinal)
|
||||
.ThenBy(workflow => workflow.WorkflowId, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
GateProfiles: gateProfiles
|
||||
.OrderBy(profile => profile.RegionId, StringComparer.Ordinal)
|
||||
.ThenBy(profile => profile.EnvironmentId, StringComparer.Ordinal)
|
||||
.ThenBy(profile => profile.ProfileName, StringComparer.Ordinal)
|
||||
.ThenBy(profile => profile.GateProfileId, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
private static TopologyPageResult<TItem> Page<TItem>(
|
||||
IReadOnlyList<TItem> items,
|
||||
int? limit,
|
||||
int? offset)
|
||||
{
|
||||
var normalizedLimit = NormalizeLimit(limit);
|
||||
var normalizedOffset = NormalizeOffset(offset);
|
||||
var paged = items
|
||||
.Skip(normalizedOffset)
|
||||
.Take(normalizedLimit)
|
||||
.ToArray();
|
||||
|
||||
return new TopologyPageResult<TItem>(paged, items.Count, normalizedLimit, normalizedOffset);
|
||||
}
|
||||
|
||||
private static int NormalizeLimit(int? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => DefaultLimit,
|
||||
< 1 => 1,
|
||||
> MaxLimit => MaxLimit,
|
||||
_ => value.Value
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeOffset(int? value)
|
||||
{
|
||||
return value is null or < 0 ? 0 : value.Value;
|
||||
}
|
||||
|
||||
private static HashSet<string> ParseFilterSet(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return new HashSet<string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return value
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(static item => NormalizeOptional(item))
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(
|
||||
string regionId,
|
||||
string environmentId,
|
||||
HashSet<string> regionFilter,
|
||||
HashSet<string> environmentFilter)
|
||||
{
|
||||
var matchesRegion = regionFilter.Count == 0 || regionFilter.Contains(regionId);
|
||||
var matchesEnvironment = environmentFilter.Count == 0 || environmentFilter.Contains(environmentId);
|
||||
return matchesRegion && matchesEnvironment;
|
||||
}
|
||||
|
||||
private static string MapRunStatusToHealth(string? status)
|
||||
{
|
||||
return status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"succeeded" => "healthy",
|
||||
"completed" => "healthy",
|
||||
"running" => "degraded",
|
||||
"queued" => "degraded",
|
||||
"failed" => "unhealthy",
|
||||
"cancelled" => "offline",
|
||||
_ => "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveHostStatus(IEnumerable<string> targetHealth)
|
||||
{
|
||||
var ordered = targetHealth
|
||||
.Select(status => status.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (ordered.Contains("unhealthy", StringComparer.Ordinal))
|
||||
{
|
||||
return "degraded";
|
||||
}
|
||||
|
||||
if (ordered.Contains("offline", StringComparer.Ordinal))
|
||||
{
|
||||
return "offline";
|
||||
}
|
||||
|
||||
if (ordered.Contains("degraded", StringComparer.Ordinal))
|
||||
{
|
||||
return "degraded";
|
||||
}
|
||||
|
||||
if (ordered.Contains("healthy", StringComparer.Ordinal))
|
||||
{
|
||||
return "healthy";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
private static string ResolveAgentStatus(IEnumerable<string> targetHealth)
|
||||
{
|
||||
var status = ResolveHostStatus(targetHealth);
|
||||
return status switch
|
||||
{
|
||||
"healthy" => "active",
|
||||
"degraded" => "degraded",
|
||||
"offline" => "offline",
|
||||
_ => "pending"
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapRunStatusToPathStatus(string? status)
|
||||
{
|
||||
return status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"running" => "running",
|
||||
"queued" => "pending",
|
||||
"succeeded" => "succeeded",
|
||||
"completed" => "succeeded",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "failed",
|
||||
_ => "idle"
|
||||
};
|
||||
}
|
||||
|
||||
private static string MapRunStatusToWorkflowStatus(string? status)
|
||||
{
|
||||
return status?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"running" => "running",
|
||||
"queued" => "running",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "failed",
|
||||
_ => "active"
|
||||
};
|
||||
}
|
||||
|
||||
private static string InferTargetType(string componentName, string metadataJson)
|
||||
{
|
||||
var normalizedName = componentName.Trim().ToLowerInvariant();
|
||||
var normalizedMetadata = metadataJson.Trim().ToLowerInvariant();
|
||||
var combined = $"{normalizedName} {normalizedMetadata}";
|
||||
|
||||
if (combined.Contains("compose", StringComparison.Ordinal))
|
||||
{
|
||||
return "compose_host";
|
||||
}
|
||||
|
||||
if (combined.Contains("ecs", StringComparison.Ordinal))
|
||||
{
|
||||
return "ecs_service";
|
||||
}
|
||||
|
||||
if (combined.Contains("nomad", StringComparison.Ordinal))
|
||||
{
|
||||
return "nomad_job";
|
||||
}
|
||||
|
||||
if (combined.Contains("ssh", StringComparison.Ordinal))
|
||||
{
|
||||
return "ssh_host";
|
||||
}
|
||||
|
||||
return "docker_host";
|
||||
}
|
||||
|
||||
private static bool IsProductionEnvironment(string environmentType, string environmentId)
|
||||
{
|
||||
return string.Equals(environmentType, "production", StringComparison.OrdinalIgnoreCase)
|
||||
|| environmentId.Contains("prod", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildAgentId(string regionId, string environmentId)
|
||||
{
|
||||
return $"agent-{regionId}-{environmentId}";
|
||||
}
|
||||
|
||||
private static string BuildAgentName(string regionId, string environmentId)
|
||||
{
|
||||
return $"agent-{regionId}-{environmentId}";
|
||||
}
|
||||
|
||||
private static string BuildHostId(string regionId, string environmentId, string componentToken)
|
||||
{
|
||||
return $"host-{regionId}-{environmentId}-{componentToken}";
|
||||
}
|
||||
|
||||
private static string BuildWorkflowId(string environmentId)
|
||||
{
|
||||
return $"workflow-{environmentId}";
|
||||
}
|
||||
|
||||
private static string BuildGateProfileId(string environmentId)
|
||||
{
|
||||
return $"gate-{environmentId}";
|
||||
}
|
||||
|
||||
private static string BuildPromotionPathId(string regionId, string sourceEnvironmentId, string targetEnvironmentId)
|
||||
{
|
||||
return $"path-{regionId}-{sourceEnvironmentId}-to-{targetEnvironmentId}";
|
||||
}
|
||||
|
||||
private static string NormalizeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return "component";
|
||||
}
|
||||
|
||||
var normalizedChars = value
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Select(static ch => char.IsLetterOrDigit(ch) ? ch : '-')
|
||||
.ToArray();
|
||||
var normalized = new string(normalizedChars);
|
||||
while (normalized.Contains("--", StringComparison.Ordinal))
|
||||
{
|
||||
normalized = normalized.Replace("--", "-", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
normalized = normalized.Trim('-');
|
||||
return string.IsNullOrWhiteSpace(normalized) ? "component" : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static DateTimeOffset? MaxTimestamp(IEnumerable<DateTimeOffset?> values)
|
||||
{
|
||||
var maxValue = values
|
||||
.Where(value => value.HasValue)
|
||||
.Select(value => value!.Value)
|
||||
.DefaultIfEmpty()
|
||||
.Max();
|
||||
|
||||
return maxValue == default ? null : maxValue;
|
||||
}
|
||||
|
||||
private sealed record TopologySnapshot(
|
||||
IReadOnlyList<TopologyRegionProjection> Regions,
|
||||
IReadOnlyList<TopologyEnvironmentProjection> Environments,
|
||||
IReadOnlyList<TopologyTargetProjection> Targets,
|
||||
IReadOnlyList<TopologyHostProjection> Hosts,
|
||||
IReadOnlyList<TopologyAgentProjection> Agents,
|
||||
IReadOnlyList<TopologyPromotionPathProjection> PromotionPaths,
|
||||
IReadOnlyList<TopologyWorkflowProjection> Workflows,
|
||||
IReadOnlyList<TopologyGateProfileProjection> GateProfiles);
|
||||
}
|
||||
|
||||
public sealed record TopologyPageResult<TItem>(
|
||||
IReadOnlyList<TItem> Items,
|
||||
int Total,
|
||||
int Limit,
|
||||
int Offset);
|
||||
@@ -7,6 +7,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| 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`. |
|
||||
| B22-01 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/context/*` endpoints, context scope/policy wiring, deterministic preference persistence baseline, and migration `047_GlobalContextAndFilters.sql`. |
|
||||
| B22-02 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/releases`, `/api/v2/releases/{releaseId}`, `/api/v2/releases/activity`, and `/api/v2/releases/approvals` read-model endpoints with deterministic projection ordering and migration `048_ReleaseReadModels.sql`. |
|
||||
| B22-03 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/topology/{regions,environments,targets,hosts,agents,promotion-paths,workflows,gate-profiles}` read-model endpoints, `platform.topology.read` policy mapping, and migration `049_TopologyInventory.sql`. |
|
||||
| B22-04 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/security/{findings,disposition/{findingId},sbom-explorer}` read contracts, `platform.security.read` policy mapping, and migration `050_SecurityDispositionProjection.sql` integration. |
|
||||
| B22-05 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v2/integrations/{feeds,vex-sources}` contracts with deterministic source type/status/freshness/last-sync metadata and migration `051_IntegrationSourceHealth.sql`. |
|
||||
| B22-06 | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: shipped `/api/v1/*` compatibility aliases for Pack 22 critical surfaces and deterministic deprecation telemetry for alias usage. |
|
||||
| U-002-PLATFORM-COMPAT | DOING | Sprint `docs/implplan/SPRINT_20260218_004_Platform_local_setup_usability_hardening.md`: unblock local console usability by fixing legacy compatibility endpoint auth failures for authenticated admin usage. |
|
||||
| QA-PLATFORM-VERIFY-001 | DONE | run-002 verification completed; feature terminalized as `not_implemented` due missing advisory lock and LISTEN/NOTIFY implementation signals in `src/Platform` (materialized-view/rollup behaviors verified). |
|
||||
| QA-PLATFORM-VERIFY-002 | DONE | run-001 verification passed with maintenance, endpoint (503 + success), service caching, and schema integration evidence; feature moved to `docs/features/checked/platform/materialized-views-for-analytics.md`. |
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
-- SPRINT_20260220_018 / B22-01
|
||||
-- Pack 22 global context baseline: regions, environments, and per-user selectors.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.context_regions (
|
||||
region_id text PRIMARY KEY,
|
||||
display_name text NOT NULL,
|
||||
sort_order integer NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_platform_context_regions_sort
|
||||
ON platform.context_regions (sort_order, region_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.context_environments (
|
||||
environment_id text PRIMARY KEY,
|
||||
region_id text NOT NULL REFERENCES platform.context_regions(region_id) ON DELETE RESTRICT,
|
||||
environment_type text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
sort_order integer NOT NULL,
|
||||
enabled boolean NOT NULL DEFAULT true
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_context_environments_region_sort
|
||||
ON platform.context_environments (region_id, sort_order, environment_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_context_environments_sort
|
||||
ON platform.context_environments (sort_order, region_id, environment_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.ui_context_preferences (
|
||||
tenant_id text NOT NULL,
|
||||
actor_id text NOT NULL,
|
||||
regions text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
environments text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
time_window text NOT NULL DEFAULT '24h',
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by text NOT NULL DEFAULT 'system',
|
||||
PRIMARY KEY (tenant_id, actor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_platform_ui_context_preferences_updated
|
||||
ON platform.ui_context_preferences (updated_at DESC, tenant_id, actor_id);
|
||||
|
||||
ALTER TABLE platform.ui_context_preferences
|
||||
DROP CONSTRAINT IF EXISTS ck_platform_ui_context_preferences_time_window;
|
||||
|
||||
ALTER TABLE platform.ui_context_preferences
|
||||
ADD CONSTRAINT ck_platform_ui_context_preferences_time_window
|
||||
CHECK (time_window IN ('1h', '24h', '7d', '30d', '90d'));
|
||||
|
||||
INSERT INTO platform.context_regions (region_id, display_name, sort_order, enabled)
|
||||
VALUES
|
||||
('us-east', 'US East', 10, true),
|
||||
('eu-west', 'EU West', 20, true),
|
||||
('apac', 'APAC', 30, true)
|
||||
ON CONFLICT (region_id) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
enabled = EXCLUDED.enabled;
|
||||
|
||||
INSERT INTO platform.context_environments (
|
||||
environment_id,
|
||||
region_id,
|
||||
environment_type,
|
||||
display_name,
|
||||
sort_order,
|
||||
enabled
|
||||
)
|
||||
VALUES
|
||||
('us-prod', 'us-east', 'production', 'US Production', 10, true),
|
||||
('us-uat', 'us-east', 'staging', 'US UAT', 11, true),
|
||||
('eu-prod', 'eu-west', 'production', 'EU Production', 20, true),
|
||||
('eu-stage', 'eu-west', 'staging', 'EU Staging', 21, true),
|
||||
('apac-prod', 'apac', 'production', 'APAC Production', 30, true)
|
||||
ON CONFLICT (environment_id) DO UPDATE SET
|
||||
region_id = EXCLUDED.region_id,
|
||||
environment_type = EXCLUDED.environment_type,
|
||||
display_name = EXCLUDED.display_name,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
enabled = EXCLUDED.enabled;
|
||||
@@ -0,0 +1,86 @@
|
||||
-- SPRINT_20260220_018 / B22-02
|
||||
-- Pack 22 releases read-model projections (list/detail/activity/approvals queue).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.release_read_model (
|
||||
release_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
bundle_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
latest_version_id uuid NULL REFERENCES release.control_bundle_versions(id) ON DELETE SET NULL,
|
||||
release_name text NOT NULL,
|
||||
release_slug text NOT NULL,
|
||||
release_type text NOT NULL DEFAULT 'standard',
|
||||
release_status text NOT NULL DEFAULT 'draft',
|
||||
gate_status text NOT NULL DEFAULT 'pending',
|
||||
pending_approvals integer NOT NULL DEFAULT 0,
|
||||
blocking_reasons text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
risk_delta jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
target_environment text NULL,
|
||||
target_region text NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_release_read_model_tenant_bundle
|
||||
ON release.release_read_model (tenant_id, bundle_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_release_read_model_tenant_status_region
|
||||
ON release.release_read_model (tenant_id, release_status, target_region, updated_at DESC, release_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.release_activity_projection (
|
||||
activity_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
release_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
run_id uuid NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE SET NULL,
|
||||
approval_id text NULL,
|
||||
event_type text NOT NULL,
|
||||
event_status text NOT NULL,
|
||||
target_environment text NULL,
|
||||
target_region text NULL,
|
||||
actor_id text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_activity_projection_tenant_occurred
|
||||
ON release.release_activity_projection (tenant_id, occurred_at DESC, activity_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_activity_projection_release
|
||||
ON release.release_activity_projection (release_id, occurred_at DESC, activity_id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.release_approvals_projection (
|
||||
approval_id text PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
release_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
run_id uuid NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE SET NULL,
|
||||
approval_status text NOT NULL,
|
||||
required_approvals integer NOT NULL DEFAULT 1,
|
||||
current_approvals integer NOT NULL DEFAULT 0,
|
||||
blockers text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
requested_by text NOT NULL,
|
||||
requested_at timestamptz NOT NULL,
|
||||
source_environment text NULL,
|
||||
target_environment text NULL,
|
||||
target_region text NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_approvals_projection_tenant_status
|
||||
ON release.release_approvals_projection (tenant_id, approval_status, requested_at DESC, approval_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_approvals_projection_release
|
||||
ON release.release_approvals_projection (release_id, requested_at DESC, approval_id);
|
||||
|
||||
ALTER TABLE release.release_read_model
|
||||
DROP CONSTRAINT IF EXISTS ck_release_release_read_model_type;
|
||||
|
||||
ALTER TABLE release.release_read_model
|
||||
ADD CONSTRAINT ck_release_release_read_model_type
|
||||
CHECK (release_type IN ('standard', 'hotfix'));
|
||||
|
||||
ALTER TABLE release.release_approvals_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_release_approvals_projection_status;
|
||||
|
||||
ALTER TABLE release.release_approvals_projection
|
||||
ADD CONSTRAINT ck_release_approvals_projection_status
|
||||
CHECK (approval_status IN ('pending', 'approved', 'rejected'));
|
||||
@@ -0,0 +1,267 @@
|
||||
-- SPRINT_20260220_018 / B22-03
|
||||
-- Pack 22 topology inventory read-model projections (regions, environments, targets, hosts, agents, paths, workflows, gate profiles).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_region_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
sort_order integer NOT NULL,
|
||||
environment_count integer NOT NULL DEFAULT 0,
|
||||
target_count integer NOT NULL DEFAULT 0,
|
||||
host_count integer NOT NULL DEFAULT 0,
|
||||
agent_count integer NOT NULL DEFAULT 0,
|
||||
last_sync_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, region_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_region_inventory_sort
|
||||
ON release.topology_region_inventory (tenant_id, sort_order, region_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_environment_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_type text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
sort_order integer NOT NULL,
|
||||
target_count integer NOT NULL DEFAULT 0,
|
||||
host_count integer NOT NULL DEFAULT 0,
|
||||
agent_count integer NOT NULL DEFAULT 0,
|
||||
promotion_path_count integer NOT NULL DEFAULT 0,
|
||||
workflow_count integer NOT NULL DEFAULT 0,
|
||||
last_sync_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, environment_id),
|
||||
CONSTRAINT fk_topology_environment_inventory_region
|
||||
FOREIGN KEY (tenant_id, region_id)
|
||||
REFERENCES release.topology_region_inventory (tenant_id, region_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_environment_inventory_region_sort
|
||||
ON release.topology_environment_inventory (tenant_id, region_id, sort_order, environment_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_target_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
target_id text NOT NULL,
|
||||
target_name text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
host_id text NOT NULL,
|
||||
agent_id text NOT NULL,
|
||||
target_type text NOT NULL,
|
||||
health_status text NOT NULL,
|
||||
component_version_id text NOT NULL,
|
||||
image_digest text NOT NULL,
|
||||
release_id uuid NULL,
|
||||
release_version_id uuid NULL,
|
||||
last_sync_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, target_id),
|
||||
CONSTRAINT fk_topology_target_inventory_environment
|
||||
FOREIGN KEY (tenant_id, environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_target_inventory_env_order
|
||||
ON release.topology_target_inventory (tenant_id, region_id, environment_id, target_name, target_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_host_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
host_id text NOT NULL,
|
||||
host_name text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
runtime_type text NOT NULL,
|
||||
host_status text NOT NULL,
|
||||
agent_id text NOT NULL,
|
||||
target_count integer NOT NULL DEFAULT 0,
|
||||
last_seen_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, host_id),
|
||||
CONSTRAINT fk_topology_host_inventory_environment
|
||||
FOREIGN KEY (tenant_id, environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_host_inventory_env_order
|
||||
ON release.topology_host_inventory (tenant_id, region_id, environment_id, host_name, host_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_agent_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
agent_id text NOT NULL,
|
||||
agent_name text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
agent_status text NOT NULL,
|
||||
capabilities text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
assigned_target_count integer NOT NULL DEFAULT 0,
|
||||
last_heartbeat_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, agent_id),
|
||||
CONSTRAINT fk_topology_agent_inventory_environment
|
||||
FOREIGN KEY (tenant_id, environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_agent_inventory_env_order
|
||||
ON release.topology_agent_inventory (tenant_id, region_id, environment_id, agent_name, agent_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_promotion_path_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
path_id text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
source_environment_id text NOT NULL,
|
||||
target_environment_id text NOT NULL,
|
||||
path_mode text NOT NULL,
|
||||
path_status text NOT NULL,
|
||||
required_approvals integer NOT NULL DEFAULT 1,
|
||||
workflow_id text NOT NULL,
|
||||
gate_profile_id text NOT NULL,
|
||||
last_promoted_at timestamptz NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, path_id),
|
||||
CONSTRAINT fk_topology_promotion_path_source_environment
|
||||
FOREIGN KEY (tenant_id, source_environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_topology_promotion_path_target_environment
|
||||
FOREIGN KEY (tenant_id, target_environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_promotion_path_inventory_order
|
||||
ON release.topology_promotion_path_inventory (tenant_id, region_id, source_environment_id, target_environment_id, path_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_workflow_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
workflow_id text NOT NULL,
|
||||
workflow_name text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
trigger_type text NOT NULL,
|
||||
workflow_status text NOT NULL,
|
||||
step_count integer NOT NULL DEFAULT 0,
|
||||
gate_profile_id text NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, workflow_id),
|
||||
CONSTRAINT fk_topology_workflow_inventory_environment
|
||||
FOREIGN KEY (tenant_id, environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_workflow_inventory_order
|
||||
ON release.topology_workflow_inventory (tenant_id, region_id, environment_id, workflow_name, workflow_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_gate_profile_inventory (
|
||||
tenant_id uuid NOT NULL,
|
||||
gate_profile_id text NOT NULL,
|
||||
profile_name text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
policy_profile text NOT NULL,
|
||||
required_approvals integer NOT NULL DEFAULT 1,
|
||||
separation_of_duties boolean NOT NULL DEFAULT false,
|
||||
blocking_rules text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, gate_profile_id),
|
||||
CONSTRAINT fk_topology_gate_profile_inventory_environment
|
||||
FOREIGN KEY (tenant_id, environment_id)
|
||||
REFERENCES release.topology_environment_inventory (tenant_id, environment_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_gate_profile_inventory_order
|
||||
ON release.topology_gate_profile_inventory (tenant_id, region_id, environment_id, profile_name, gate_profile_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.topology_sync_watermarks (
|
||||
tenant_id uuid NOT NULL,
|
||||
projection_name text NOT NULL,
|
||||
last_synced_at timestamptz NOT NULL,
|
||||
source_cursor text NULL,
|
||||
status text NOT NULL DEFAULT 'idle',
|
||||
error_message text NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, projection_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_topology_sync_watermarks_synced
|
||||
ON release.topology_sync_watermarks (projection_name, last_synced_at DESC, tenant_id);
|
||||
|
||||
ALTER TABLE release.topology_environment_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_environment_inventory_type;
|
||||
|
||||
ALTER TABLE release.topology_environment_inventory
|
||||
ADD CONSTRAINT ck_topology_environment_inventory_type
|
||||
CHECK (environment_type IN ('development', 'staging', 'production'));
|
||||
|
||||
ALTER TABLE release.topology_target_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_target_inventory_type;
|
||||
|
||||
ALTER TABLE release.topology_target_inventory
|
||||
ADD CONSTRAINT ck_topology_target_inventory_type
|
||||
CHECK (target_type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job', 'ssh_host'));
|
||||
|
||||
ALTER TABLE release.topology_target_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_target_inventory_health;
|
||||
|
||||
ALTER TABLE release.topology_target_inventory
|
||||
ADD CONSTRAINT ck_topology_target_inventory_health
|
||||
CHECK (health_status IN ('healthy', 'degraded', 'unhealthy', 'offline', 'unknown'));
|
||||
|
||||
ALTER TABLE release.topology_host_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_host_inventory_status;
|
||||
|
||||
ALTER TABLE release.topology_host_inventory
|
||||
ADD CONSTRAINT ck_topology_host_inventory_status
|
||||
CHECK (host_status IN ('healthy', 'degraded', 'offline', 'unknown'));
|
||||
|
||||
ALTER TABLE release.topology_agent_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_agent_inventory_status;
|
||||
|
||||
ALTER TABLE release.topology_agent_inventory
|
||||
ADD CONSTRAINT ck_topology_agent_inventory_status
|
||||
CHECK (agent_status IN ('active', 'degraded', 'offline', 'pending'));
|
||||
|
||||
ALTER TABLE release.topology_promotion_path_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_promotion_path_inventory_mode;
|
||||
|
||||
ALTER TABLE release.topology_promotion_path_inventory
|
||||
ADD CONSTRAINT ck_topology_promotion_path_inventory_mode
|
||||
CHECK (path_mode IN ('manual', 'auto_on_success', 'emergency'));
|
||||
|
||||
ALTER TABLE release.topology_promotion_path_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_promotion_path_inventory_status;
|
||||
|
||||
ALTER TABLE release.topology_promotion_path_inventory
|
||||
ADD CONSTRAINT ck_topology_promotion_path_inventory_status
|
||||
CHECK (path_status IN ('idle', 'pending', 'running', 'failed', 'succeeded'));
|
||||
|
||||
ALTER TABLE release.topology_workflow_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_workflow_inventory_trigger;
|
||||
|
||||
ALTER TABLE release.topology_workflow_inventory
|
||||
ADD CONSTRAINT ck_topology_workflow_inventory_trigger
|
||||
CHECK (trigger_type IN ('manual', 'promotion', 'schedule', 'release_created'));
|
||||
|
||||
ALTER TABLE release.topology_workflow_inventory
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_workflow_inventory_status;
|
||||
|
||||
ALTER TABLE release.topology_workflow_inventory
|
||||
ADD CONSTRAINT ck_topology_workflow_inventory_status
|
||||
CHECK (workflow_status IN ('active', 'inactive', 'running', 'failed', 'idle'));
|
||||
|
||||
ALTER TABLE release.topology_sync_watermarks
|
||||
DROP CONSTRAINT IF EXISTS ck_topology_sync_watermarks_status;
|
||||
|
||||
ALTER TABLE release.topology_sync_watermarks
|
||||
ADD CONSTRAINT ck_topology_sync_watermarks_status
|
||||
CHECK (status IN ('idle', 'running', 'failed', 'succeeded'));
|
||||
@@ -0,0 +1,171 @@
|
||||
-- SPRINT_20260220_018 / B22-04
|
||||
-- Pack 22 security consolidation read projections (findings/disposition/SBOM explorer).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.security_finding_projection (
|
||||
tenant_id uuid NOT NULL,
|
||||
finding_id text NOT NULL,
|
||||
cve_id text NOT NULL,
|
||||
severity text NOT NULL,
|
||||
package_name text NOT NULL,
|
||||
component_name text NOT NULL,
|
||||
release_id uuid NULL,
|
||||
release_name text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
reachable boolean NOT NULL DEFAULT false,
|
||||
reachability_score integer NOT NULL DEFAULT 0,
|
||||
effective_disposition text NOT NULL,
|
||||
vex_status text NOT NULL,
|
||||
exception_status text NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, finding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_finding_projection_filters
|
||||
ON release.security_finding_projection (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
severity,
|
||||
effective_disposition,
|
||||
updated_at DESC,
|
||||
finding_id
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_finding_projection_cve
|
||||
ON release.security_finding_projection (tenant_id, cve_id, finding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.security_disposition_projection (
|
||||
tenant_id uuid NOT NULL,
|
||||
finding_id text NOT NULL,
|
||||
cve_id text NOT NULL,
|
||||
release_id uuid NULL,
|
||||
release_name text NOT NULL,
|
||||
package_name text NOT NULL,
|
||||
component_name text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
vex_status text NOT NULL,
|
||||
vex_justification text NOT NULL,
|
||||
vex_statement_id text NULL,
|
||||
vex_updated_at timestamptz NULL,
|
||||
exception_status text NOT NULL,
|
||||
exception_reason text NOT NULL,
|
||||
exception_approval_state text NOT NULL,
|
||||
exception_id text NULL,
|
||||
exception_expires_at timestamptz NULL,
|
||||
exception_updated_at timestamptz NULL,
|
||||
effective_disposition text NOT NULL,
|
||||
policy_action text NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, finding_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_disposition_projection_filters
|
||||
ON release.security_disposition_projection (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
effective_disposition,
|
||||
updated_at DESC,
|
||||
finding_id
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_disposition_projection_release
|
||||
ON release.security_disposition_projection (tenant_id, release_id, finding_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.security_sbom_component_projection (
|
||||
tenant_id uuid NOT NULL,
|
||||
component_id text NOT NULL,
|
||||
release_id uuid NULL,
|
||||
release_name text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
package_name text NOT NULL,
|
||||
component_name text NOT NULL,
|
||||
component_version text NOT NULL,
|
||||
supplier text NOT NULL,
|
||||
license text NOT NULL,
|
||||
vulnerability_count integer NOT NULL DEFAULT 0,
|
||||
critical_reachable_count integer NOT NULL DEFAULT 0,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, component_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_sbom_component_projection_filters
|
||||
ON release.security_sbom_component_projection (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
release_name,
|
||||
component_name,
|
||||
component_id
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.security_sbom_graph_projection (
|
||||
tenant_id uuid NOT NULL,
|
||||
edge_id text NOT NULL,
|
||||
from_node_id text NOT NULL,
|
||||
to_node_id text NOT NULL,
|
||||
relation_type text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, edge_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_security_sbom_graph_projection_filters
|
||||
ON release.security_sbom_graph_projection (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
from_node_id,
|
||||
to_node_id,
|
||||
edge_id
|
||||
);
|
||||
|
||||
ALTER TABLE release.security_finding_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_finding_projection_severity;
|
||||
|
||||
ALTER TABLE release.security_finding_projection
|
||||
ADD CONSTRAINT ck_security_finding_projection_severity
|
||||
CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'));
|
||||
|
||||
ALTER TABLE release.security_finding_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_finding_projection_disposition;
|
||||
|
||||
ALTER TABLE release.security_finding_projection
|
||||
ADD CONSTRAINT ck_security_finding_projection_disposition
|
||||
CHECK (effective_disposition IN ('accepted_risk', 'mitigated', 'action_required', 'review_required'));
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_vex_status;
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
ADD CONSTRAINT ck_security_disposition_projection_vex_status
|
||||
CHECK (vex_status IN ('affected', 'not_affected', 'under_investigation'));
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_exception_status;
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
ADD CONSTRAINT ck_security_disposition_projection_exception_status
|
||||
CHECK (exception_status IN ('none', 'pending', 'approved', 'rejected'));
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_approval_state;
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
ADD CONSTRAINT ck_security_disposition_projection_approval_state
|
||||
CHECK (exception_approval_state IN ('not_requested', 'awaiting_review', 'approved', 'rejected'));
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
DROP CONSTRAINT IF EXISTS ck_security_disposition_projection_policy_action;
|
||||
|
||||
ALTER TABLE release.security_disposition_projection
|
||||
ADD CONSTRAINT ck_security_disposition_projection_policy_action
|
||||
CHECK (policy_action IN ('allow_with_exception', 'allow', 'block', 'review'));
|
||||
@@ -0,0 +1,128 @@
|
||||
-- SPRINT_20260220_018 / B22-05
|
||||
-- Pack 22 integrations feed and VEX source health projections.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.integration_feed_source_health (
|
||||
tenant_id uuid NOT NULL,
|
||||
source_id text NOT NULL,
|
||||
source_name text NOT NULL,
|
||||
source_type text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
status text NOT NULL,
|
||||
freshness text NOT NULL,
|
||||
freshness_minutes integer NULL,
|
||||
sla_minutes integer NOT NULL,
|
||||
last_sync_at timestamptz NULL,
|
||||
last_success_at timestamptz NULL,
|
||||
last_error text NULL,
|
||||
consumer_domains text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, source_id, region_id, environment_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_integration_feed_source_health_filters
|
||||
ON release.integration_feed_source_health (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
status,
|
||||
freshness,
|
||||
source_type,
|
||||
source_name
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.integration_vex_source_health (
|
||||
tenant_id uuid NOT NULL,
|
||||
source_id text NOT NULL,
|
||||
source_name text NOT NULL,
|
||||
source_type text NOT NULL,
|
||||
provider text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
status text NOT NULL,
|
||||
freshness text NOT NULL,
|
||||
freshness_minutes integer NULL,
|
||||
sla_minutes integer NOT NULL,
|
||||
statement_format text NOT NULL,
|
||||
document_count_24h integer NOT NULL DEFAULT 0,
|
||||
last_sync_at timestamptz NULL,
|
||||
last_success_at timestamptz NULL,
|
||||
last_error text NULL,
|
||||
consumer_domains text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, source_id, region_id, environment_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_integration_vex_source_health_filters
|
||||
ON release.integration_vex_source_health (
|
||||
tenant_id,
|
||||
region_id,
|
||||
environment_id,
|
||||
status,
|
||||
freshness,
|
||||
source_type,
|
||||
source_name
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.integration_source_sync_watermarks (
|
||||
tenant_id uuid NOT NULL,
|
||||
source_family text NOT NULL,
|
||||
region_id text NOT NULL,
|
||||
environment_id text NOT NULL,
|
||||
last_synced_at timestamptz NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL,
|
||||
PRIMARY KEY (tenant_id, source_family, region_id, environment_id)
|
||||
);
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_source_type;
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
ADD CONSTRAINT ck_integration_feed_source_health_source_type
|
||||
CHECK (source_type IN ('advisory_feed'));
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_status;
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
ADD CONSTRAINT ck_integration_feed_source_health_status
|
||||
CHECK (status IN ('healthy', 'degraded', 'offline'));
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_feed_source_health_freshness;
|
||||
|
||||
ALTER TABLE release.integration_feed_source_health
|
||||
ADD CONSTRAINT ck_integration_feed_source_health_freshness
|
||||
CHECK (freshness IN ('fresh', 'stale', 'unknown'));
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_source_type;
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
ADD CONSTRAINT ck_integration_vex_source_health_source_type
|
||||
CHECK (source_type IN ('vex_source'));
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_status;
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
ADD CONSTRAINT ck_integration_vex_source_health_status
|
||||
CHECK (status IN ('healthy', 'degraded', 'offline'));
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_freshness;
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
ADD CONSTRAINT ck_integration_vex_source_health_freshness
|
||||
CHECK (freshness IN ('fresh', 'stale', 'unknown'));
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
DROP CONSTRAINT IF EXISTS ck_integration_vex_source_health_statement_format;
|
||||
|
||||
ALTER TABLE release.integration_vex_source_health
|
||||
ADD CONSTRAINT ck_integration_vex_source_health_statement_format
|
||||
CHECK (statement_format IN ('openvex', 'csaf_vex'));
|
||||
@@ -0,0 +1,44 @@
|
||||
-- SPRINT_20260220_023 / B23-RUN-02
|
||||
-- Run input snapshot persistence for deterministic run-detail provenance.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.run_input_snapshots (
|
||||
snapshot_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
|
||||
bundle_id uuid NOT NULL REFERENCES release.control_bundles(id) ON DELETE CASCADE,
|
||||
bundle_version_id uuid NOT NULL REFERENCES release.control_bundle_versions(id) ON DELETE CASCADE,
|
||||
policy_pack_version text NOT NULL,
|
||||
feed_snapshot_id text NOT NULL,
|
||||
feed_freshness_status text NOT NULL DEFAULT 'unknown',
|
||||
feed_freshness_minutes integer NULL,
|
||||
sbom_snapshot_id text NOT NULL,
|
||||
sbom_job_id text NULL,
|
||||
reachability_snapshot_id text NOT NULL,
|
||||
reachability_job_id text NULL,
|
||||
reachability_coverage_percent integer NOT NULL DEFAULT 0,
|
||||
reachability_evidence_age_minutes integer NULL,
|
||||
vex_snapshot_ref text NULL,
|
||||
disposition_snapshot_ref text NULL,
|
||||
captured_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_input_snapshots_tenant_run
|
||||
ON release.run_input_snapshots (tenant_id, run_id, captured_at DESC, snapshot_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_input_snapshots_tenant_bundle_version
|
||||
ON release.run_input_snapshots (tenant_id, bundle_id, bundle_version_id, captured_at DESC, snapshot_id);
|
||||
|
||||
ALTER TABLE release.run_input_snapshots
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_input_snapshots_feed_freshness;
|
||||
|
||||
ALTER TABLE release.run_input_snapshots
|
||||
ADD CONSTRAINT ck_release_run_input_snapshots_feed_freshness
|
||||
CHECK (feed_freshness_status IN ('fresh', 'stale', 'unknown'));
|
||||
|
||||
ALTER TABLE release.run_input_snapshots
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_input_snapshots_coverage_percent;
|
||||
|
||||
ALTER TABLE release.run_input_snapshots
|
||||
ADD CONSTRAINT ck_release_run_input_snapshots_coverage_percent
|
||||
CHECK (reachability_coverage_percent BETWEEN 0 AND 100);
|
||||
@@ -0,0 +1,44 @@
|
||||
-- SPRINT_20260220_023 / B23-RUN-03
|
||||
-- Run gate decision ledger for provenance and explainability.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.run_gate_decision_ledger (
|
||||
gate_decision_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
|
||||
snapshot_id uuid NULL REFERENCES release.run_input_snapshots(snapshot_id) ON DELETE SET NULL,
|
||||
verdict text NOT NULL,
|
||||
machine_reason_codes text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
human_reason_codes text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
risk_budget_delta numeric(12, 4) NOT NULL DEFAULT 0,
|
||||
risk_budget_contributors jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
staleness_verdict text NOT NULL DEFAULT 'unknown',
|
||||
staleness_threshold_minutes integer NOT NULL DEFAULT 0,
|
||||
blocked_items text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
evaluated_by text NOT NULL,
|
||||
evaluated_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_gate_decision_ledger_tenant_run
|
||||
ON release.run_gate_decision_ledger (tenant_id, run_id, evaluated_at DESC, gate_decision_id DESC);
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_verdict;
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
ADD CONSTRAINT ck_release_run_gate_decision_ledger_verdict
|
||||
CHECK (verdict IN ('allow', 'review', 'block'));
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_staleness;
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
ADD CONSTRAINT ck_release_run_gate_decision_ledger_staleness
|
||||
CHECK (staleness_verdict IN ('fresh', 'stale', 'unknown'));
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_gate_decision_ledger_threshold;
|
||||
|
||||
ALTER TABLE release.run_gate_decision_ledger
|
||||
ADD CONSTRAINT ck_release_run_gate_decision_ledger_threshold
|
||||
CHECK (staleness_threshold_minutes >= 0);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- SPRINT_20260220_023 / B23-RUN-04
|
||||
-- Ordered run approval checkpoints with signature and rationale trails.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.run_approval_checkpoints (
|
||||
checkpoint_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
|
||||
checkpoint_order integer NOT NULL,
|
||||
checkpoint_name text NOT NULL,
|
||||
required_role text NOT NULL,
|
||||
status text NOT NULL,
|
||||
approver_id text NULL,
|
||||
approved_at timestamptz NULL,
|
||||
signature_algorithm text NULL,
|
||||
signature_value text NULL,
|
||||
rationale text NULL,
|
||||
evidence_proof_ref text NULL,
|
||||
correlation_key text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_approval_checkpoints_order
|
||||
ON release.run_approval_checkpoints (tenant_id, run_id, checkpoint_order);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_approval_checkpoints_status
|
||||
ON release.run_approval_checkpoints (tenant_id, run_id, status, updated_at DESC, checkpoint_order);
|
||||
|
||||
ALTER TABLE release.run_approval_checkpoints
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_approval_checkpoints_order;
|
||||
|
||||
ALTER TABLE release.run_approval_checkpoints
|
||||
ADD CONSTRAINT ck_release_run_approval_checkpoints_order
|
||||
CHECK (checkpoint_order > 0);
|
||||
|
||||
ALTER TABLE release.run_approval_checkpoints
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_approval_checkpoints_status;
|
||||
|
||||
ALTER TABLE release.run_approval_checkpoints
|
||||
ADD CONSTRAINT ck_release_run_approval_checkpoints_status
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'skipped'));
|
||||
@@ -0,0 +1,55 @@
|
||||
-- SPRINT_20260220_023 / B23-RUN-05
|
||||
-- Run deployment phase timeline and rollback trigger/outcome lineage.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.run_deployment_timeline (
|
||||
deployment_event_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
|
||||
target_id text NOT NULL,
|
||||
target_name text NOT NULL,
|
||||
target_environment text NOT NULL,
|
||||
target_region text NOT NULL,
|
||||
strategy text NOT NULL,
|
||||
phase text NOT NULL,
|
||||
status text NOT NULL,
|
||||
artifact_digest text NULL,
|
||||
log_ref text NULL,
|
||||
rollback_trigger_id text NULL,
|
||||
rollback_outcome text NULL,
|
||||
occurred_at timestamptz NOT NULL,
|
||||
correlation_key text NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_deployment_timeline_run
|
||||
ON release.run_deployment_timeline (tenant_id, run_id, occurred_at DESC, deployment_event_id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_deployment_timeline_filters
|
||||
ON release.run_deployment_timeline (tenant_id, target_environment, target_region, status, occurred_at DESC);
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_strategy;
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
ADD CONSTRAINT ck_release_run_deployment_timeline_strategy
|
||||
CHECK (strategy IN ('canary', 'rolling', 'blue_green', 'recreate'));
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_phase;
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
ADD CONSTRAINT ck_release_run_deployment_timeline_phase
|
||||
CHECK (phase IN ('queued', 'precheck', 'deploying', 'verifying', 'completed', 'rollback'));
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_status;
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
ADD CONSTRAINT ck_release_run_deployment_timeline_status
|
||||
CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'rolled_back'));
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_deployment_timeline_rollback_outcome;
|
||||
|
||||
ALTER TABLE release.run_deployment_timeline
|
||||
ADD CONSTRAINT ck_release_run_deployment_timeline_rollback_outcome
|
||||
CHECK (COALESCE(rollback_outcome, 'none') IN ('none', 'triggered', 'succeeded', 'failed'));
|
||||
@@ -0,0 +1,48 @@
|
||||
-- SPRINT_20260220_023 / B23-RUN-06
|
||||
-- Decision capsule and replay linkage per release run.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS release.run_capsule_replay_linkage (
|
||||
linkage_id uuid PRIMARY KEY,
|
||||
tenant_id uuid NOT NULL,
|
||||
run_id uuid NOT NULL REFERENCES release.control_bundle_materialization_runs(run_id) ON DELETE CASCADE,
|
||||
decision_capsule_id text NOT NULL,
|
||||
capsule_hash text NOT NULL,
|
||||
signature_status text NOT NULL,
|
||||
transparency_receipt text NULL,
|
||||
replay_verdict text NOT NULL,
|
||||
replay_match boolean NOT NULL,
|
||||
replay_mismatch_report_ref text NULL,
|
||||
replay_checked_at timestamptz NULL,
|
||||
audit_stream_ref text NULL,
|
||||
export_profiles text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
correlation_key text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_release_run_capsule_replay_linkage_run
|
||||
ON release.run_capsule_replay_linkage (tenant_id, run_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_release_run_capsule_replay_linkage_verdict
|
||||
ON release.run_capsule_replay_linkage (tenant_id, replay_verdict, updated_at DESC, linkage_id DESC);
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_signature_status;
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_signature_status
|
||||
CHECK (signature_status IN ('signed', 'unsigned', 'invalid'));
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_replay_verdict;
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_replay_verdict
|
||||
CHECK (replay_verdict IN ('match', 'mismatch', 'not_available'));
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
DROP CONSTRAINT IF EXISTS ck_release_run_capsule_replay_linkage_mismatch_report;
|
||||
|
||||
ALTER TABLE release.run_capsule_replay_linkage
|
||||
ADD CONSTRAINT ck_release_run_capsule_replay_linkage_mismatch_report
|
||||
CHECK (replay_verdict <> 'mismatch' OR replay_mismatch_report_ref IS NOT NULL);
|
||||
@@ -4,5 +4,10 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
|
||||
|
||||
| Task ID | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| 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. |
|
||||
| B22-04-DB | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added release migration `050_SecurityDispositionProjection.sql` with consolidated findings/disposition/SBOM read-model projection tables, filters, and enum constraints. |
|
||||
| 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. |
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ContextEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ContextEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RegionsAndEnvironments_ReturnDeterministicOrdering()
|
||||
{
|
||||
using var client = CreateTenantClient("tenant-context-1");
|
||||
|
||||
var regionsFirst = await client.GetFromJsonAsync<PlatformContextRegion[]>(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
var regionsSecond = await client.GetFromJsonAsync<PlatformContextRegion[]>(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(regionsFirst);
|
||||
Assert.NotNull(regionsSecond);
|
||||
Assert.Equal(
|
||||
new[] { "us-east", "eu-west", "apac" },
|
||||
regionsFirst!.Select(region => region.RegionId).ToArray());
|
||||
Assert.Equal(
|
||||
regionsFirst.Select(region => region.RegionId).ToArray(),
|
||||
regionsSecond!.Select(region => region.RegionId).ToArray());
|
||||
|
||||
var environments = await client.GetFromJsonAsync<PlatformContextEnvironment[]>(
|
||||
"/api/v2/context/environments?regions=eu-west,us-east,eu-west",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(environments);
|
||||
Assert.Equal(
|
||||
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
|
||||
environments!.Select(environment => environment.EnvironmentId).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Preferences_DefaultAndRoundTrip_AreDeterministic()
|
||||
{
|
||||
using var client = CreateTenantClient("tenant-context-2");
|
||||
|
||||
var defaults = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(defaults);
|
||||
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, defaults!.Regions.ToArray());
|
||||
Assert.Empty(defaults.Environments);
|
||||
Assert.Equal("24h", defaults.TimeWindow);
|
||||
|
||||
var request = new PlatformContextPreferencesRequest(
|
||||
Regions: new[] { "eu-west", "us-east", "unknown", "US-EAST" },
|
||||
Environments: new[] { "eu-stage", "us-prod", "unknown", "apac-prod", "eu-prod", "US-PROD" },
|
||||
TimeWindow: "7d");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v2/context/preferences",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await response.Content.ReadFromJsonAsync<PlatformContextPreferences>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(new[] { "us-east", "eu-west" }, updated!.Regions.ToArray());
|
||||
Assert.Equal(new[] { "us-prod", "eu-prod", "eu-stage" }, updated.Environments.ToArray());
|
||||
Assert.Equal("7d", updated.TimeWindow);
|
||||
|
||||
var stored = await client.GetFromJsonAsync<PlatformContextPreferences>(
|
||||
"/api/v2/context/preferences",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(updated.Regions.ToArray(), stored!.Regions.ToArray());
|
||||
Assert.Equal(updated.Environments.ToArray(), stored.Environments.ToArray());
|
||||
Assert.Equal(updated.TimeWindow, stored.TimeWindow);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ContextEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync(
|
||||
"/api/v2/context/regions",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ContextEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/context/regions", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/environments", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/preferences", "GET", PlatformPolicies.ContextRead);
|
||||
AssertPolicy(endpoints, "/api/v2/context/preferences", "PUT", PlatformPolicies.ContextWrite);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, routePattern, StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "context-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ContextMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration047_DefinesGlobalContextSchemaObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("047_GlobalContextAndFilters.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_regions", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.context_environments", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.ui_context_preferences", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (time_window IN ('1h', '24h', '7d', '30d', '90d'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("INSERT INTO platform.context_regions", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("INSERT INTO platform.context_environments", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration047_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index046 = Array.IndexOf(migrationNames, "046_TrustSigningAdministration.sql");
|
||||
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
|
||||
|
||||
Assert.True(index046 >= 0, "Expected migration 046 to exist.");
|
||||
Assert.True(index047 > index046, "Expected migration 047 to appear after migration 046.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IntegrationSourceHealthMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration051_DefinesIntegrationSourceHealthProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("051_IntegrationSourceHealth.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_feed_source_health", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_vex_source_health", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.integration_source_sync_watermarks", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (source_type IN ('advisory_feed'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (source_type IN ('vex_source'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('healthy', 'degraded', 'offline'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (freshness IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (statement_format IN ('openvex', 'csaf_vex'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration051_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
|
||||
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
|
||||
|
||||
Assert.True(index050 >= 0, "Expected migration 050 to exist.");
|
||||
Assert.True(index051 > index050, "Expected migration 051 to appear after migration 050.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IntegrationsReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public IntegrationsReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IntegrationsEndpoints_ReturnDeterministicFeedAndVexSourceHealth_WithSecurityAndDashboardConsumerMetadata()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "checkout-integrations", "Checkout Integrations", "us-prod", "feed-refresh");
|
||||
await SeedReleaseAsync(client, "billing-integrations", "Billing Integrations", "eu-prod", "vex-sync");
|
||||
|
||||
var feedsFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var feedsSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(feedsFirst);
|
||||
Assert.NotNull(feedsSecond);
|
||||
Assert.NotEmpty(feedsFirst!.Items);
|
||||
Assert.Equal(
|
||||
feedsFirst.Items.Select(item => item.SourceId).ToArray(),
|
||||
feedsSecond!.Items.Select(item => item.SourceId).ToArray());
|
||||
|
||||
Assert.All(feedsFirst.Items, item =>
|
||||
{
|
||||
Assert.Equal("advisory_feed", item.SourceType);
|
||||
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
|
||||
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
|
||||
Assert.Contains("security-findings", item.ConsumerDomains);
|
||||
Assert.Contains("dashboard-posture", item.ConsumerDomains);
|
||||
});
|
||||
|
||||
var usFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?region=us-east&sourceType=advisory_feed&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usFeeds);
|
||||
Assert.NotEmpty(usFeeds!.Items);
|
||||
Assert.All(usFeeds.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-east", item.Region);
|
||||
Assert.Equal("advisory_feed", item.SourceType);
|
||||
});
|
||||
|
||||
var usProdFeeds = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?environment=us-prod&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usProdFeeds);
|
||||
Assert.NotEmpty(usProdFeeds!.Items);
|
||||
Assert.All(usProdFeeds.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-prod", item.Environment);
|
||||
Assert.NotNull(item.LastSyncAt);
|
||||
Assert.NotNull(item.FreshnessMinutes);
|
||||
});
|
||||
|
||||
var vexFirst = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var vexSecond = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(vexFirst);
|
||||
Assert.NotNull(vexSecond);
|
||||
Assert.NotEmpty(vexFirst!.Items);
|
||||
Assert.Equal(
|
||||
vexFirst.Items.Select(item => item.SourceId).ToArray(),
|
||||
vexSecond!.Items.Select(item => item.SourceId).ToArray());
|
||||
|
||||
Assert.All(vexFirst.Items, item =>
|
||||
{
|
||||
Assert.Equal("vex_source", item.SourceType);
|
||||
Assert.Equal("openvex", item.StatementFormat);
|
||||
Assert.Contains(item.Status, new[] { "healthy", "degraded", "offline" });
|
||||
Assert.Contains(item.Freshness, new[] { "fresh", "stale", "unknown" });
|
||||
Assert.Contains("security-disposition", item.ConsumerDomains);
|
||||
Assert.Contains("dashboard-posture", item.ConsumerDomains);
|
||||
Assert.True(item.DocumentCount24h >= 20);
|
||||
});
|
||||
|
||||
var euVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?environment=eu-prod&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euVex);
|
||||
Assert.NotEmpty(euVex!.Items);
|
||||
Assert.All(euVex.Items, item => Assert.Equal("eu-prod", item.Environment));
|
||||
|
||||
var offlineVex = await client.GetFromJsonAsync<PlatformListResponse<IntegrationVexSourceProjection>>(
|
||||
"/api/v2/integrations/vex-sources?environment=us-uat&status=offline&limit=200&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(offlineVex);
|
||||
Assert.NotEmpty(offlineVex!.Items);
|
||||
Assert.All(offlineVex.Items, item =>
|
||||
{
|
||||
Assert.Equal("us-uat", item.Environment);
|
||||
Assert.Equal("offline", item.Status);
|
||||
Assert.Equal("unknown", item.Freshness);
|
||||
Assert.Null(item.LastSyncAt);
|
||||
});
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task IntegrationsEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/integrations/feeds", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void IntegrationsEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/integrations/feeds", "GET", PlatformPolicies.IntegrationsRead);
|
||||
AssertPolicy(endpoints, "/api/v2/integrations/vex-sources", "GET", PlatformPolicies.IntegrationsVexRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "integrations-v2-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using StellaOps.TestKit;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class LegacyAliasCompatibilityTelemetryTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public LegacyAliasCompatibilityTelemetryTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CriticalPack22Surfaces_ExposeV1Aliases_AndEmitDeterministicDeprecationTelemetry()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
var telemetry = _factory.Services.GetRequiredService<LegacyAliasTelemetry>();
|
||||
telemetry.Clear();
|
||||
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
await SeedReleaseAsync(client, "alias-release", "Alias Release", "us-prod", "alias-check");
|
||||
|
||||
var v1Context = await client.GetAsync("/api/v1/context/regions", TestContext.Current.CancellationToken);
|
||||
var v2Context = await client.GetAsync("/api/v2/context/regions", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.OK, v1Context.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, v2Context.StatusCode);
|
||||
|
||||
var v1Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v1/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Releases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Releases);
|
||||
Assert.NotNull(v2Releases);
|
||||
Assert.NotEmpty(v1Releases!.Items);
|
||||
Assert.NotEmpty(v2Releases!.Items);
|
||||
|
||||
var v1Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v1/releases/runs?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Runs = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Runs);
|
||||
Assert.NotNull(v2Runs);
|
||||
Assert.NotEmpty(v1Runs!.Items);
|
||||
Assert.NotEmpty(v2Runs!.Items);
|
||||
|
||||
var sampleRunId = v1Runs.Items[0].RunId;
|
||||
var v1RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v1/releases/runs/{sampleRunId}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2RunTimeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v2/releases/runs/{sampleRunId}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1RunTimeline);
|
||||
Assert.NotNull(v2RunTimeline);
|
||||
Assert.Equal(sampleRunId, v1RunTimeline!.Item.RunId);
|
||||
Assert.Equal(sampleRunId, v2RunTimeline!.Item.RunId);
|
||||
|
||||
var v1Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v1/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Topology = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v2/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Topology);
|
||||
Assert.NotNull(v2Topology);
|
||||
Assert.NotEmpty(v1Topology!.Items);
|
||||
Assert.NotEmpty(v2Topology!.Items);
|
||||
|
||||
var v1Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v1/security/findings?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Security = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Security);
|
||||
Assert.NotNull(v2Security);
|
||||
Assert.NotEmpty(v1Security!.Items);
|
||||
Assert.NotEmpty(v2Security!.Items);
|
||||
|
||||
var v1Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v1/integrations/feeds?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var v2Integrations = await client.GetFromJsonAsync<PlatformListResponse<IntegrationFeedProjection>>(
|
||||
"/api/v2/integrations/feeds?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(v1Integrations);
|
||||
Assert.NotNull(v2Integrations);
|
||||
Assert.NotEmpty(v1Integrations!.Items);
|
||||
Assert.NotEmpty(v2Integrations!.Items);
|
||||
|
||||
var usage = telemetry.Snapshot();
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/context/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/context/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_context_regions", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/releases", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/releases", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_releases", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
item.AliasRoute.StartsWith("/api/v1/releases/runs", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.StartsWith("/api/v2/releases/runs", StringComparison.Ordinal)
|
||||
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
item.AliasRoute.Contains("/api/v1/releases/runs/", StringComparison.Ordinal)
|
||||
&& item.AliasRoute.EndsWith("/timeline", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.Contains("/api/v2/releases/runs/", StringComparison.Ordinal)
|
||||
&& item.CanonicalRoute.EndsWith("/timeline", StringComparison.Ordinal)
|
||||
&& item.EventKey.StartsWith("alias_get_api_v1_releases_runs", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/topology/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/topology/regions", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_topology_regions", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/security/findings", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/security/findings", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_security_findings", StringComparison.Ordinal));
|
||||
Assert.Contains(usage, item =>
|
||||
string.Equals(item.AliasRoute, "/api/v1/integrations/feeds", StringComparison.Ordinal)
|
||||
&& string.Equals(item.CanonicalRoute, "/api/v2/integrations/feeds", StringComparison.Ordinal)
|
||||
&& string.Equals(item.EventKey, "alias_get_api_v1_integrations_feeds", StringComparison.Ordinal));
|
||||
|
||||
Assert.DoesNotContain(usage, item =>
|
||||
item.TenantHash is not null
|
||||
&& item.TenantHash.Contains(tenantId, StringComparison.OrdinalIgnoreCase));
|
||||
Assert.All(
|
||||
usage.Where(item => item.AliasRoute.StartsWith("/api/v1/", StringComparison.Ordinal)),
|
||||
item => Assert.StartsWith("alias_", item.EventKey));
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "legacy-alias-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public ReleaseReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesListDetailActivityAndApprovals_ReturnDeterministicProjections()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var checkout = await SeedReleaseAsync(
|
||||
client,
|
||||
"checkout-hotfix",
|
||||
"Checkout Hotfix",
|
||||
"us-prod",
|
||||
"critical-fix");
|
||||
|
||||
var payments = await SeedReleaseAsync(
|
||||
client,
|
||||
"payments-release",
|
||||
"Payments Release",
|
||||
"eu-prod",
|
||||
"policy-review");
|
||||
|
||||
var releasesFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var releasesSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(releasesFirst);
|
||||
Assert.NotNull(releasesSecond);
|
||||
Assert.True(releasesFirst!.Items.Count >= 2);
|
||||
Assert.Equal(
|
||||
releasesFirst.Items.Select(item => item.ReleaseId).ToArray(),
|
||||
releasesSecond!.Items.Select(item => item.ReleaseId).ToArray());
|
||||
|
||||
var hotfix = releasesFirst.Items.Single(item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
|
||||
Assert.Equal("hotfix", hotfix.ReleaseType);
|
||||
Assert.Equal("pending_approval", hotfix.Status);
|
||||
Assert.Equal("us-prod", hotfix.TargetEnvironment);
|
||||
Assert.Equal("us-east", hotfix.TargetRegion);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseDetailProjection>>(
|
||||
$"/api/v2/releases/{checkout.Bundle.Id:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(checkout.Bundle.Id.ToString("D"), detail!.Item.Summary.ReleaseId);
|
||||
Assert.NotEmpty(detail.Item.Versions);
|
||||
Assert.Contains(detail.Item.Approvals, approval =>
|
||||
string.Equals(approval.TargetEnvironment, "us-prod", StringComparison.Ordinal));
|
||||
|
||||
var activityFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var activitySecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(activityFirst);
|
||||
Assert.NotNull(activitySecond);
|
||||
Assert.Contains(activityFirst!.Items, item => item.ReleaseId == checkout.Bundle.Id.ToString("D"));
|
||||
Assert.Contains(activityFirst.Items, item => item.ReleaseId == payments.Bundle.Id.ToString("D"));
|
||||
Assert.Equal(
|
||||
activityFirst.Items.Select(item => item.ActivityId).ToArray(),
|
||||
activitySecond!.Items.Select(item => item.ActivityId).ToArray());
|
||||
|
||||
var approvalsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var approvalsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(approvalsFirst);
|
||||
Assert.NotNull(approvalsSecond);
|
||||
Assert.True(approvalsFirst!.Items.Count >= 2);
|
||||
Assert.All(approvalsFirst.Items, item => Assert.Equal("pending", item.Status));
|
||||
Assert.Equal(
|
||||
approvalsFirst.Items.Select(item => item.ApprovalId).ToArray(),
|
||||
approvalsSecond!.Items.Select(item => item.ApprovalId).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_ApplyRegionEnvironmentAndStatusFilters()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "orders-hotfix", "Orders Hotfix", "us-prod", "critical-fix");
|
||||
await SeedReleaseAsync(client, "inventory-release", "Inventory Release", "eu-prod", "policy-review");
|
||||
|
||||
var usReleases = await client.GetFromJsonAsync<PlatformListResponse<ReleaseProjection>>(
|
||||
"/api/v2/releases?region=us-east&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usReleases);
|
||||
Assert.NotEmpty(usReleases!.Items);
|
||||
Assert.All(usReleases.Items, item => Assert.Equal("us-east", item.TargetRegion));
|
||||
|
||||
var euActivity = await client.GetFromJsonAsync<PlatformListResponse<ReleaseActivityProjection>>(
|
||||
"/api/v2/releases/activity?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euActivity);
|
||||
Assert.NotEmpty(euActivity!.Items);
|
||||
Assert.All(euActivity.Items, item => Assert.Equal("eu-prod", item.TargetEnvironment));
|
||||
|
||||
var euApprovals = await client.GetFromJsonAsync<PlatformListResponse<ReleaseApprovalProjection>>(
|
||||
"/api/v2/releases/approvals?status=pending®ion=eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euApprovals);
|
||||
Assert.NotEmpty(euApprovals!.Items);
|
||||
Assert.All(euApprovals.Items, item => Assert.Equal("eu-west", item.TargetRegion));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task ReleasesEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/releases", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void ReleasesEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/releases", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/{releaseId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/activity", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/approvals", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: slug,
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "releases-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseReadModelMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration048_DefinesReleaseReadModelProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("048_ReleaseReadModels.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_read_model", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_activity_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.release_approvals_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("correlation_key", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (release_type IN ('standard', 'hotfix'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (approval_status IN ('pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration048_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index047 = Array.IndexOf(migrationNames, "047_GlobalContextAndFilters.sql");
|
||||
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
|
||||
|
||||
Assert.True(index047 >= 0, "Expected migration 047 to exist.");
|
||||
Assert.True(index048 > index047, "Expected migration 048 to appear after migration 047.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class ReleaseRunEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public ReleaseRunEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunEndpoints_ReturnDeterministicRunCentricContractsAcrossTabs()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var hotfix = await SeedReleaseAsync(client, "checkout-hotfix", "Checkout Hotfix", "us-prod", "stale-integrity-window");
|
||||
await SeedReleaseAsync(client, "payments-release", "Payments Release", "eu-prod", "routine-promotion");
|
||||
|
||||
var runsFirst = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var runsSecond = await client.GetFromJsonAsync<PlatformListResponse<ReleaseRunProjection>>(
|
||||
"/api/v2/releases/runs?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(runsFirst);
|
||||
Assert.NotNull(runsSecond);
|
||||
Assert.True(runsFirst!.Items.Count >= 2);
|
||||
Assert.Equal(
|
||||
runsFirst.Items.Select(item => item.RunId).ToArray(),
|
||||
runsSecond!.Items.Select(item => item.RunId).ToArray());
|
||||
|
||||
var hotfixProjection = runsFirst.Items.Single(item => item.RunId == hotfix.Run.RunId.ToString("D"));
|
||||
Assert.Equal("hotfix", hotfixProjection.Lane);
|
||||
Assert.Equal("us-prod", hotfixProjection.TargetEnvironment);
|
||||
Assert.Equal("us-east", hotfixProjection.TargetRegion);
|
||||
Assert.True(hotfixProjection.BlockedByDataIntegrity);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDetailProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), detail!.Item.RunId);
|
||||
Assert.Equal("connect", detail.Item.Process[0].StepId);
|
||||
Assert.Equal(5, detail.Item.Process.Count);
|
||||
|
||||
var timeline = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunTimelineProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/timeline",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), timeline!.Item.RunId);
|
||||
Assert.True(timeline.Item.Events.Count >= 6);
|
||||
Assert.Contains(timeline.Item.Correlations, item => string.Equals(item.Type, "snapshot_id", StringComparison.Ordinal));
|
||||
|
||||
var gate = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunGateDecisionProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/gate-decision",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(gate);
|
||||
Assert.Equal(hotfix.Run.RunId.ToString("D"), gate!.Item.RunId);
|
||||
Assert.NotEmpty(gate.Item.PolicyPackVersion);
|
||||
Assert.NotEmpty(gate.Item.MachineReasonCodes);
|
||||
|
||||
var approvals = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunApprovalsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/approvals",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(approvals);
|
||||
Assert.Equal(new[] { 1, 2 }, approvals!.Item.Checkpoints.Select(item => item.Order).ToArray());
|
||||
|
||||
var deployments = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunDeploymentsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/deployments",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(deployments);
|
||||
Assert.NotEmpty(deployments!.Item.Targets);
|
||||
Assert.NotEmpty(deployments.Item.RollbackTriggers);
|
||||
|
||||
var securityInputs = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunSecurityInputsProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/security-inputs",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(securityInputs);
|
||||
Assert.InRange(securityInputs!.Item.ReachabilityCoveragePercent, 0, 100);
|
||||
Assert.NotEmpty(securityInputs.Item.Drilldowns);
|
||||
|
||||
var evidence = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunEvidenceProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/evidence",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Contains("capsule-", evidence!.Item.DecisionCapsuleId, StringComparison.Ordinal);
|
||||
|
||||
var rollback = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunRollbackProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/rollback",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(rollback);
|
||||
Assert.NotEmpty(rollback!.Item.KnownGoodReferences);
|
||||
|
||||
var replay = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunReplayProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/replay",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(replay);
|
||||
Assert.Contains(replay!.Item.Verdict, new[] { "match", "mismatch", "not_available" });
|
||||
|
||||
var audit = await client.GetFromJsonAsync<PlatformItemResponse<ReleaseRunAuditProjection>>(
|
||||
$"/api/v2/releases/runs/{hotfix.Run.RunId:D}/audit",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(audit);
|
||||
Assert.NotEmpty(audit!.Item.Entries);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task RunEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/releases/runs", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void RunEndpoints_RequireExpectedPolicies()
|
||||
{
|
||||
var endpoints = factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/timeline", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/gate-decision", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/approvals", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/deployments", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/security-inputs", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/evidence", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/rollback", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/replay", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
AssertPolicy(endpoints, "/api/v2/releases/runs/{runId:guid}/audit", "GET", PlatformPolicies.ReleaseControlRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: slug,
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"track\":\"stable\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "release-runs-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunApprovalCheckpointsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration054_DefinesRunApprovalCheckpointObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("054_RunApprovalCheckpoints.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_approval_checkpoints", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_approval_checkpoints_order", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("signature_algorithm", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("signature_value", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('pending', 'approved', 'rejected', 'skipped'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration054_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
|
||||
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
|
||||
|
||||
Assert.True(index053 >= 0, "Expected migration 053 to exist.");
|
||||
Assert.True(index054 > index053, "Expected migration 054 to appear after migration 053.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunCapsuleReplayLinkageMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration056_DefinesRunCapsuleReplayLinkageObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("056_RunCapsuleReplayLinkage.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_capsule_replay_linkage", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_capsule_replay_linkage_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_capsule_replay_linkage_verdict", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (signature_status IN ('signed', 'unsigned', 'invalid'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (replay_verdict IN ('match', 'mismatch', 'not_available'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (replay_verdict <> 'mismatch' OR replay_mismatch_report_ref IS NOT NULL)", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration056_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
|
||||
var index056 = Array.IndexOf(migrationNames, "056_RunCapsuleReplayLinkage.sql");
|
||||
|
||||
Assert.True(index055 >= 0, "Expected migration 055 to exist.");
|
||||
Assert.True(index056 > index055, "Expected migration 056 to appear after migration 055.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunDeploymentTimelineMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration055_DefinesRunDeploymentTimelineObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("055_RunDeploymentTimeline.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_deployment_timeline", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_deployment_timeline_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_deployment_timeline_filters", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (strategy IN ('canary', 'rolling', 'blue_green', 'recreate'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (phase IN ('queued', 'precheck', 'deploying', 'verifying', 'completed', 'rollback'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (status IN ('pending', 'running', 'succeeded', 'failed', 'rolled_back'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration055_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index054 = Array.IndexOf(migrationNames, "054_RunApprovalCheckpoints.sql");
|
||||
var index055 = Array.IndexOf(migrationNames, "055_RunDeploymentTimeline.sql");
|
||||
|
||||
Assert.True(index054 >= 0, "Expected migration 054 to exist.");
|
||||
Assert.True(index055 > index054, "Expected migration 055 to appear after migration 054.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunGateDecisionLedgerMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration053_DefinesRunGateDecisionLedgerObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("053_RunGateDecisionLedger.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_gate_decision_ledger", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("risk_budget_delta", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("risk_budget_contributors", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (verdict IN ('allow', 'review', 'block'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (staleness_verdict IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration053_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
|
||||
var index053 = Array.IndexOf(migrationNames, "053_RunGateDecisionLedger.sql");
|
||||
|
||||
Assert.True(index052 >= 0, "Expected migration 052 to exist.");
|
||||
Assert.True(index053 > index052, "Expected migration 053 to appear after migration 052.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class RunInputSnapshotsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration052_DefinesRunInputSnapshotObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("052_RunInputSnapshots.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.run_input_snapshots", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ux_release_run_input_snapshots_tenant_run", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("ix_release_run_input_snapshots_tenant_bundle_version", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (feed_freshness_status IN ('fresh', 'stale', 'unknown'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (reachability_coverage_percent BETWEEN 0 AND 100)", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration052_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index051 = Array.IndexOf(migrationNames, "051_IntegrationSourceHealth.sql");
|
||||
var index052 = Array.IndexOf(migrationNames, "052_RunInputSnapshots.sql");
|
||||
|
||||
Assert.True(index051 >= 0, "Expected migration 051 to exist.");
|
||||
Assert.True(index052 > index051, "Expected migration 052 to appear after migration 051.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SecurityDispositionMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration050_DefinesSecurityDispositionProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("050_SecurityDispositionProjection.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_finding_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_disposition_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_component_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.security_sbom_graph_projection", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (vex_status IN ('affected', 'not_affected', 'under_investigation'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (exception_status IN ('none', 'pending', 'approved', 'rejected'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration050_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
|
||||
var index050 = Array.IndexOf(migrationNames, "050_SecurityDispositionProjection.sql");
|
||||
|
||||
Assert.True(index049 >= 0, "Expected migration 049 to exist.");
|
||||
Assert.True(index050 > index049, "Expected migration 050 to appear after migration 049.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class SecurityReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public SecurityReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SecurityEndpoints_ReturnDeterministicFindingsDispositionAndSbomExplorer()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
var checkout = await SeedReleaseAsync(client, "checkout-security-release", "Checkout Security Release", "us-prod", "security-exception");
|
||||
var billing = await SeedReleaseAsync(client, "billing-security-release", "Billing Security Release", "eu-prod", "policy-review");
|
||||
|
||||
var findingsFirst = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var findingsSecond = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?pivot=component&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(findingsFirst);
|
||||
Assert.NotNull(findingsSecond);
|
||||
Assert.NotEmpty(findingsFirst!.Items);
|
||||
Assert.NotEmpty(findingsFirst.PivotBuckets);
|
||||
Assert.NotEmpty(findingsFirst.Facets);
|
||||
Assert.Equal(
|
||||
findingsFirst.Items.Select(item => item.FindingId).ToArray(),
|
||||
findingsSecond!.Items.Select(item => item.FindingId).ToArray());
|
||||
|
||||
var usFindings = await client.GetFromJsonAsync<SecurityFindingsResponse>(
|
||||
"/api/v2/security/findings?region=us-east&severity=critical&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usFindings);
|
||||
Assert.All(usFindings!.Items, item => Assert.Equal("us-east", item.Region));
|
||||
Assert.All(usFindings.Items, item => Assert.Equal("critical", item.Severity));
|
||||
|
||||
var dispositionList = await client.GetFromJsonAsync<PlatformListResponse<SecurityDispositionProjection>>(
|
||||
"/api/v2/security/disposition?limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(dispositionList);
|
||||
Assert.NotEmpty(dispositionList!.Items);
|
||||
Assert.All(dispositionList.Items, item =>
|
||||
{
|
||||
Assert.Equal("vex", item.Vex.SourceModel);
|
||||
Assert.Equal("exceptions", item.Exception.SourceModel);
|
||||
});
|
||||
|
||||
var firstFindingId = dispositionList.Items[0].FindingId;
|
||||
var dispositionDetail = await client.GetFromJsonAsync<PlatformItemResponse<SecurityDispositionProjection>>(
|
||||
$"/api/v2/security/disposition/{firstFindingId}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(dispositionDetail);
|
||||
Assert.Equal(firstFindingId, dispositionDetail!.Item.FindingId);
|
||||
|
||||
var sbomTable = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
"/api/v2/security/sbom-explorer?mode=table&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomTable);
|
||||
Assert.Equal("table", sbomTable!.Mode);
|
||||
Assert.NotEmpty(sbomTable.Table);
|
||||
Assert.Empty(sbomTable.GraphNodes);
|
||||
Assert.Empty(sbomTable.GraphEdges);
|
||||
|
||||
var sbomGraph = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
"/api/v2/security/sbom-explorer?mode=graph&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomGraph);
|
||||
Assert.Equal("graph", sbomGraph!.Mode);
|
||||
Assert.NotEmpty(sbomGraph.GraphNodes);
|
||||
Assert.NotEmpty(sbomGraph.GraphEdges);
|
||||
|
||||
var sbomDiff = await client.GetFromJsonAsync<SecuritySbomExplorerResponse>(
|
||||
$"/api/v2/security/sbom-explorer?mode=diff&leftReleaseId={checkout.Bundle.Id:D}&rightReleaseId={billing.Bundle.Id:D}&limit=50&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(sbomDiff);
|
||||
Assert.Equal("diff", sbomDiff!.Mode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task SecurityEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/security/findings", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void SecurityEndpoints_RequireExpectedPolicies_AndDoNotExposeCombinedWriteRoute()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/security/findings", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/disposition", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/disposition/{findingId}", "GET", PlatformPolicies.SecurityRead);
|
||||
AssertPolicy(endpoints, "/api/v2/security/sbom-explorer", "GET", PlatformPolicies.SecurityRead);
|
||||
|
||||
var hasCombinedWrite = endpoints.Any(candidate =>
|
||||
string.Equals(candidate.RoutePattern.RawText, "/api/v2/security/disposition/exceptions", StringComparison.Ordinal)
|
||||
&& candidate.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("POST", StringComparer.OrdinalIgnoreCase) == true);
|
||||
Assert.False(hasCombinedWrite);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task<SeededRelease> SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var run = await materializeResponse.Content.ReadFromJsonAsync<ReleaseControlBundleMaterializationRun>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(run);
|
||||
|
||||
return new SeededRelease(bundle, version, run!);
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "security-v2-tests");
|
||||
return client;
|
||||
}
|
||||
|
||||
private sealed record SeededRelease(
|
||||
ReleaseControlBundleDetail Bundle,
|
||||
ReleaseControlBundleVersionDetail Version,
|
||||
ReleaseControlBundleMaterializationRun Run);
|
||||
}
|
||||
@@ -7,6 +7,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| --- | --- | --- |
|
||||
| PACK-ADM-01-T | DONE | Added/verified `PackAdapterEndpointsTests` coverage for `/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}` and deterministic alias ordering assertions. |
|
||||
| PACK-ADM-02-T | DONE | Added `AdministrationTrustSigningMutationEndpointsTests` covering trust-owner key/issuer/certificate/transparency lifecycle plus route metadata policy bindings for `platform.trust.read`, `platform.trust.write`, and `platform.trust.admin`. |
|
||||
| B22-01-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ContextEndpointsTests` + `ContextMigrationScriptTests` for `/api/v2/context/*` deterministic ordering, preference round-trip behavior, and migration `047` coverage. |
|
||||
| B22-02-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `ReleaseReadModelEndpointsTests` + `ReleaseReadModelMigrationScriptTests` for `/api/v2/releases{,/activity,/approvals,/{releaseId}}` deterministic projection behavior and migration `048` coverage. |
|
||||
| B22-03-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `TopologyReadModelEndpointsTests` + `TopologyInventoryMigrationScriptTests` for `/api/v2/topology/*` deterministic ordering/filter behavior and migration `049` coverage. |
|
||||
| B22-04-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `SecurityReadModelEndpointsTests` + `SecurityDispositionMigrationScriptTests` for `/api/v2/security/{findings,disposition,sbom-explorer}` deterministic behavior, policy metadata, write-boundary checks, and migration `050` coverage. |
|
||||
| B22-05-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added `IntegrationsReadModelEndpointsTests` + `IntegrationSourceHealthMigrationScriptTests` for `/api/v2/integrations/{feeds,vex-sources}` deterministic behavior, policy metadata, consumer compatibility, and migration `051` coverage. |
|
||||
| B22-06-T | DONE | Sprint `docs/implplan/SPRINT_20260220_018_Platform_pack22_backend_contracts_and_migrations.md`: added compatibility+telemetry contract tests covering both `/api/v1/*` aliases and `/api/v2/*` canonical routes for critical Pack 22 surfaces. |
|
||||
| AUDIT-0762-M | DONE | Revalidated 2026-01-07 (test project). |
|
||||
| AUDIT-0762-T | DONE | Revalidated 2026-01-07. |
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class TopologyInventoryMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration049_DefinesTopologyInventoryProjectionObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("049_TopologyInventory.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_region_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_environment_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_target_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_host_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_agent_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_promotion_path_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_workflow_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_gate_profile_inventory", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS release.topology_sync_watermarks", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (target_type IN ('docker_host', 'compose_host', 'ecs_service', 'nomad_job', 'ssh_host'))", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CHECK (path_status IN ('idle', 'pending', 'running', 'failed', 'succeeded'))", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration049_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index048 = Array.IndexOf(migrationNames, "048_ReleaseReadModels.sql");
|
||||
var index049 = Array.IndexOf(migrationNames, "049_TopologyInventory.sql");
|
||||
|
||||
Assert.True(index048 >= 0, "Expected migration 048 to exist.");
|
||||
Assert.True(index049 > index048, "Expected migration 049 to appear after migration 048.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public TopologyReadModelEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TopologyEndpoints_ReturnDeterministicInventoryAndSupportFilters()
|
||||
{
|
||||
var tenantId = Guid.NewGuid().ToString("D");
|
||||
using var client = CreateTenantClient(tenantId);
|
||||
|
||||
await SeedReleaseAsync(client, "orders-release", "Orders Release", "us-prod", "promotion");
|
||||
await SeedReleaseAsync(client, "billing-release", "Billing Release", "eu-prod", "promotion");
|
||||
|
||||
var regions = await client.GetFromJsonAsync<PlatformListResponse<TopologyRegionProjection>>(
|
||||
"/api/v2/topology/regions?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(regions);
|
||||
Assert.Equal(new[] { "us-east", "eu-west", "apac" }, regions!.Items.Select(item => item.RegionId).ToArray());
|
||||
|
||||
var environments = await client.GetFromJsonAsync<PlatformListResponse<TopologyEnvironmentProjection>>(
|
||||
"/api/v2/topology/environments?region=us-east,eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(environments);
|
||||
Assert.Equal(
|
||||
new[] { "us-prod", "us-uat", "eu-prod", "eu-stage" },
|
||||
environments!.Items.Select(item => item.EnvironmentId).ToArray());
|
||||
|
||||
var targetsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var targetsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(targetsFirst);
|
||||
Assert.NotNull(targetsSecond);
|
||||
Assert.NotEmpty(targetsFirst!.Items);
|
||||
Assert.Equal(
|
||||
targetsFirst.Items.Select(item => item.TargetId).ToArray(),
|
||||
targetsSecond!.Items.Select(item => item.TargetId).ToArray());
|
||||
|
||||
var hostsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var hostsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(hostsFirst);
|
||||
Assert.NotNull(hostsSecond);
|
||||
Assert.NotEmpty(hostsFirst!.Items);
|
||||
Assert.Equal(
|
||||
hostsFirst.Items.Select(item => item.HostId).ToArray(),
|
||||
hostsSecond!.Items.Select(item => item.HostId).ToArray());
|
||||
|
||||
var agentsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
|
||||
"/api/v2/topology/agents?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
var agentsSecond = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
|
||||
"/api/v2/topology/agents?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(agentsFirst);
|
||||
Assert.NotNull(agentsSecond);
|
||||
Assert.NotEmpty(agentsFirst!.Items);
|
||||
Assert.Equal(
|
||||
agentsFirst.Items.Select(item => item.AgentId).ToArray(),
|
||||
agentsSecond!.Items.Select(item => item.AgentId).ToArray());
|
||||
|
||||
var paths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
|
||||
"/api/v2/topology/promotion-paths?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(paths);
|
||||
Assert.NotEmpty(paths!.Items);
|
||||
|
||||
var workflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
|
||||
"/api/v2/topology/workflows?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(workflows);
|
||||
Assert.NotEmpty(workflows!.Items);
|
||||
|
||||
var profiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
|
||||
"/api/v2/topology/gate-profiles?limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(profiles);
|
||||
Assert.NotEmpty(profiles!.Items);
|
||||
|
||||
var usTargets = await client.GetFromJsonAsync<PlatformListResponse<TopologyTargetProjection>>(
|
||||
"/api/v2/topology/targets?region=us-east&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usTargets);
|
||||
Assert.NotEmpty(usTargets!.Items);
|
||||
Assert.All(usTargets.Items, item => Assert.Equal("us-east", item.RegionId));
|
||||
|
||||
var euHosts = await client.GetFromJsonAsync<PlatformListResponse<TopologyHostProjection>>(
|
||||
"/api/v2/topology/hosts?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euHosts);
|
||||
Assert.NotEmpty(euHosts!.Items);
|
||||
Assert.All(euHosts.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
|
||||
|
||||
var usPaths = await client.GetFromJsonAsync<PlatformListResponse<TopologyPromotionPathProjection>>(
|
||||
"/api/v2/topology/promotion-paths?environment=us-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(usPaths);
|
||||
Assert.NotEmpty(usPaths!.Items);
|
||||
Assert.All(usPaths.Items, item =>
|
||||
Assert.True(
|
||||
string.Equals(item.SourceEnvironmentId, "us-prod", StringComparison.Ordinal)
|
||||
|| string.Equals(item.TargetEnvironmentId, "us-prod", StringComparison.Ordinal)));
|
||||
|
||||
var euWorkflows = await client.GetFromJsonAsync<PlatformListResponse<TopologyWorkflowProjection>>(
|
||||
"/api/v2/topology/workflows?environment=eu-prod&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euWorkflows);
|
||||
Assert.NotEmpty(euWorkflows!.Items);
|
||||
Assert.All(euWorkflows.Items, item => Assert.Equal("eu-prod", item.EnvironmentId));
|
||||
|
||||
var euProfiles = await client.GetFromJsonAsync<PlatformListResponse<TopologyGateProfileProjection>>(
|
||||
"/api/v2/topology/gate-profiles?region=eu-west&limit=20&offset=0",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(euProfiles);
|
||||
Assert.NotEmpty(euProfiles!.Items);
|
||||
Assert.All(euProfiles.Items, item => Assert.Equal("eu-west", item.RegionId));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TopologyEndpoints_WithoutTenantHeader_ReturnBadRequest()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
var response = await client.GetAsync("/api/v2/topology/regions", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void TopologyEndpoints_RequireExpectedPolicy()
|
||||
{
|
||||
var endpoints = _factory.Services
|
||||
.GetRequiredService<EndpointDataSource>()
|
||||
.Endpoints
|
||||
.OfType<RouteEndpoint>()
|
||||
.ToArray();
|
||||
|
||||
AssertPolicy(endpoints, "/api/v2/topology/regions", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/environments", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/targets", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/hosts", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/agents", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/promotion-paths", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/workflows", "GET", PlatformPolicies.TopologyRead);
|
||||
AssertPolicy(endpoints, "/api/v2/topology/gate-profiles", "GET", PlatformPolicies.TopologyRead);
|
||||
}
|
||||
|
||||
private static void AssertPolicy(
|
||||
IReadOnlyList<RouteEndpoint> endpoints,
|
||||
string routePattern,
|
||||
string method,
|
||||
string expectedPolicy)
|
||||
{
|
||||
static string NormalizePattern(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "/" : value.TrimEnd('/');
|
||||
|
||||
var expectedPattern = NormalizePattern(routePattern);
|
||||
var endpoint = endpoints.Single(candidate =>
|
||||
string.Equals(
|
||||
NormalizePattern(candidate.RoutePattern.RawText ?? string.Empty),
|
||||
expectedPattern,
|
||||
StringComparison.Ordinal)
|
||||
&& candidate.Metadata
|
||||
.GetMetadata<HttpMethodMetadata>()?
|
||||
.HttpMethods
|
||||
.Contains(method, StringComparer.OrdinalIgnoreCase) == true);
|
||||
|
||||
var policies = endpoint.Metadata
|
||||
.GetOrderedMetadata<IAuthorizeData>()
|
||||
.Select(metadata => metadata.Policy)
|
||||
.Where(static policy => !string.IsNullOrWhiteSpace(policy))
|
||||
.ToArray();
|
||||
|
||||
Assert.Contains(expectedPolicy, policies);
|
||||
}
|
||||
|
||||
private static async Task SeedReleaseAsync(
|
||||
HttpClient client,
|
||||
string slug,
|
||||
string name,
|
||||
string targetEnvironment,
|
||||
string reason)
|
||||
{
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/release-control/bundles",
|
||||
new CreateReleaseControlBundleRequest(slug, name, $"{name} description"),
|
||||
TestContext.Current.CancellationToken);
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await createResponse.Content.ReadFromJsonAsync<ReleaseControlBundleDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(bundle);
|
||||
|
||||
var publishResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle!.Id:D}/versions",
|
||||
new PublishReleaseControlBundleVersionRequest(
|
||||
Changelog: "baseline",
|
||||
Components:
|
||||
[
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.0",
|
||||
ComponentName: $"{slug}-api",
|
||||
ImageDigest: "sha256:1111111111111111111111111111111111111111111111111111111111111111",
|
||||
DeployOrder: 10,
|
||||
MetadataJson: "{\"runtime\":\"docker\"}"),
|
||||
new ReleaseControlBundleComponentInput(
|
||||
ComponentVersionId: $"{slug}@1.0.1",
|
||||
ComponentName: $"{slug}-worker",
|
||||
ImageDigest: "sha256:2222222222222222222222222222222222222222222222222222222222222222",
|
||||
DeployOrder: 20,
|
||||
MetadataJson: "{\"runtime\":\"compose\"}")
|
||||
]),
|
||||
TestContext.Current.CancellationToken);
|
||||
publishResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var version = await publishResponse.Content.ReadFromJsonAsync<ReleaseControlBundleVersionDetail>(
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(version);
|
||||
|
||||
var materializeResponse = await client.PostAsJsonAsync(
|
||||
$"/api/v1/release-control/bundles/{bundle.Id:D}/versions/{version!.Id:D}/materialize",
|
||||
new MaterializeReleaseControlBundleVersionRequest(targetEnvironment, reason, Guid.NewGuid().ToString("N")),
|
||||
TestContext.Current.CancellationToken);
|
||||
materializeResponse.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private HttpClient CreateTenantClient(string tenantId)
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "topology-v2-tests");
|
||||
return client;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user