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;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { TenantActivationService } from './core/auth/tenant-activation.service';
|
||||
import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor';
|
||||
import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor';
|
||||
import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor';
|
||||
import { seedAuthSession, type StubAuthSession } from './testing';
|
||||
import { CVSS_API_BASE_URL } from './core/api/cvss.client';
|
||||
import { AUTH_SERVICE } from './core/auth';
|
||||
@@ -124,43 +125,35 @@ import {
|
||||
RELEASE_DASHBOARD_API,
|
||||
RELEASE_DASHBOARD_API_BASE_URL,
|
||||
ReleaseDashboardHttpClient,
|
||||
MockReleaseDashboardClient,
|
||||
} from './core/api/release-dashboard.client';
|
||||
import {
|
||||
RELEASE_ENVIRONMENT_API,
|
||||
RELEASE_ENVIRONMENT_API_BASE_URL,
|
||||
ReleaseEnvironmentHttpClient,
|
||||
MockReleaseEnvironmentClient,
|
||||
} from './core/api/release-environment.client';
|
||||
import {
|
||||
RELEASE_MANAGEMENT_API,
|
||||
ReleaseManagementHttpClient,
|
||||
MockReleaseManagementClient,
|
||||
} from './core/api/release-management.client';
|
||||
import {
|
||||
WORKFLOW_API,
|
||||
WorkflowHttpClient,
|
||||
MockWorkflowClient,
|
||||
} from './core/api/workflow.client';
|
||||
import {
|
||||
APPROVAL_API,
|
||||
ApprovalHttpClient,
|
||||
MockApprovalClient,
|
||||
} from './core/api/approval.client';
|
||||
import {
|
||||
DEPLOYMENT_API,
|
||||
DeploymentHttpClient,
|
||||
MockDeploymentClient,
|
||||
} from './core/api/deployment.client';
|
||||
import {
|
||||
RELEASE_EVIDENCE_API,
|
||||
ReleaseEvidenceHttpClient,
|
||||
MockReleaseEvidenceClient,
|
||||
} from './core/api/release-evidence.client';
|
||||
import {
|
||||
DOCTOR_API,
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
} from './features/doctor/services/doctor.client';
|
||||
import {
|
||||
WITNESS_API,
|
||||
@@ -185,7 +178,6 @@ import {
|
||||
import {
|
||||
VULN_ANNOTATION_API,
|
||||
HttpVulnAnnotationClient,
|
||||
MockVulnAnnotationClient,
|
||||
} from './core/api/vuln-annotation.client';
|
||||
import {
|
||||
AUTHORITY_ADMIN_API,
|
||||
@@ -268,6 +260,11 @@ export const appConfig: ApplicationConfig = {
|
||||
useClass: TenantHttpInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: GlobalContextHttpInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: CONCELIER_EXPORTER_API_BASE_URL,
|
||||
useValue: '/api/v1/concelier/exporters/trivy-db',
|
||||
@@ -630,7 +627,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: NOTIFY_API,
|
||||
useExisting: NotifyApiHttpClient,
|
||||
},
|
||||
// Release Dashboard API (using mock - no backend endpoint yet)
|
||||
// Release Dashboard API (runtime HTTP client)
|
||||
{
|
||||
provide: RELEASE_DASHBOARD_API_BASE_URL,
|
||||
deps: [AppConfigService],
|
||||
@@ -645,7 +642,6 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ReleaseDashboardHttpClient,
|
||||
MockReleaseDashboardClient,
|
||||
{
|
||||
provide: RELEASE_DASHBOARD_API,
|
||||
useExisting: ReleaseDashboardHttpClient,
|
||||
@@ -665,49 +661,42 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
ReleaseEnvironmentHttpClient,
|
||||
MockReleaseEnvironmentClient,
|
||||
{
|
||||
provide: RELEASE_ENVIRONMENT_API,
|
||||
useExisting: ReleaseEnvironmentHttpClient,
|
||||
},
|
||||
// Release Management API (Sprint 111_003 - using mock until backend is available)
|
||||
// Release Management API (runtime HTTP client)
|
||||
ReleaseManagementHttpClient,
|
||||
MockReleaseManagementClient,
|
||||
{
|
||||
provide: RELEASE_MANAGEMENT_API,
|
||||
useExisting: ReleaseManagementHttpClient,
|
||||
},
|
||||
// Workflow API (Sprint 111_004 - using mock until backend is available)
|
||||
// Workflow API (runtime HTTP client)
|
||||
WorkflowHttpClient,
|
||||
MockWorkflowClient,
|
||||
{
|
||||
provide: WORKFLOW_API,
|
||||
useExisting: WorkflowHttpClient,
|
||||
},
|
||||
// Approval API (using mock - no backend endpoint yet)
|
||||
// Approval API (runtime HTTP client)
|
||||
ApprovalHttpClient,
|
||||
MockApprovalClient,
|
||||
{
|
||||
provide: APPROVAL_API,
|
||||
useExisting: ApprovalHttpClient,
|
||||
},
|
||||
// Deployment API (Sprint 111_006 - using mock until backend is available)
|
||||
// Deployment API (runtime HTTP client)
|
||||
DeploymentHttpClient,
|
||||
MockDeploymentClient,
|
||||
{
|
||||
provide: DEPLOYMENT_API,
|
||||
useExisting: DeploymentHttpClient,
|
||||
},
|
||||
// Release Evidence API (Sprint 111_007 - using mock until backend is available)
|
||||
// Release Evidence API (runtime HTTP client)
|
||||
ReleaseEvidenceHttpClient,
|
||||
MockReleaseEvidenceClient,
|
||||
{
|
||||
provide: RELEASE_EVIDENCE_API,
|
||||
useExisting: ReleaseEvidenceHttpClient,
|
||||
},
|
||||
// Doctor API (HTTP paths corrected; using mock until gateway auth chain is configured)
|
||||
// Doctor API (runtime HTTP client)
|
||||
HttpDoctorClient,
|
||||
MockDoctorClient,
|
||||
{
|
||||
provide: DOCTOR_API,
|
||||
useExisting: HttpDoctorClient,
|
||||
@@ -752,9 +741,8 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: TRUST_API,
|
||||
useExisting: TrustHttpService,
|
||||
},
|
||||
// Vuln Annotation API (using mock until backend is available)
|
||||
// Vuln Annotation API (runtime HTTP client)
|
||||
HttpVulnAnnotationClient,
|
||||
MockVulnAnnotationClient,
|
||||
{
|
||||
provide: VULN_ANNOTATION_API,
|
||||
useExisting: HttpVulnAnnotationClient,
|
||||
|
||||
@@ -52,58 +52,89 @@ export const routes: Routes = [
|
||||
redirectTo: '/',
|
||||
},
|
||||
|
||||
// Domain 2: Release Control
|
||||
// Domain 2: Releases
|
||||
{
|
||||
path: 'release-control',
|
||||
title: 'Release Control',
|
||||
path: 'releases',
|
||||
title: 'Releases',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Release Control' },
|
||||
data: { breadcrumb: 'Releases' },
|
||||
loadChildren: () =>
|
||||
import('./routes/release-control.routes').then(
|
||||
(m) => m.RELEASE_CONTROL_ROUTES
|
||||
import('./routes/releases.routes').then(
|
||||
(m) => m.RELEASES_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 3: Security & Risk (formerly /security)
|
||||
// Domain 3: Security
|
||||
{
|
||||
path: 'security-risk',
|
||||
title: 'Security & Risk',
|
||||
path: 'security',
|
||||
title: 'Security',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Security & Risk' },
|
||||
data: { breadcrumb: 'Security' },
|
||||
loadChildren: () =>
|
||||
import('./routes/security-risk.routes').then(
|
||||
(m) => m.SECURITY_RISK_ROUTES
|
||||
import('./routes/security.routes').then(
|
||||
(m) => m.SECURITY_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 4: Evidence and Audit (formerly /evidence)
|
||||
// Domain 4: Evidence
|
||||
{
|
||||
path: 'evidence-audit',
|
||||
title: 'Evidence & Audit',
|
||||
path: 'evidence',
|
||||
title: 'Evidence',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Evidence & Audit' },
|
||||
data: { breadcrumb: 'Evidence' },
|
||||
loadChildren: () =>
|
||||
import('./routes/evidence-audit.routes').then(
|
||||
(m) => m.EVIDENCE_AUDIT_ROUTES
|
||||
import('./routes/evidence.routes').then(
|
||||
(m) => m.EVIDENCE_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 5: Integrations (already canonical — kept as-is)
|
||||
// /integrations already loaded below; no path change for this domain.
|
||||
|
||||
// Domain 6: Platform Ops — canonical P0-P9 surface (SPRINT_20260218_008)
|
||||
// Domain 6: Topology
|
||||
{
|
||||
path: 'platform-ops',
|
||||
title: 'Platform Ops',
|
||||
path: 'topology',
|
||||
title: 'Topology',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Platform Ops' },
|
||||
data: { breadcrumb: 'Topology' },
|
||||
loadChildren: () =>
|
||||
import('./routes/platform-ops.routes').then(
|
||||
(m) => m.PLATFORM_OPS_ROUTES
|
||||
import('./routes/topology.routes').then(
|
||||
(m) => m.TOPOLOGY_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 7: Administration (canonical A0-A7 surface — SPRINT_20260218_007)
|
||||
// Domain 7: Platform
|
||||
{
|
||||
path: 'platform',
|
||||
title: 'Platform',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Platform' },
|
||||
loadChildren: () =>
|
||||
import('./routes/platform.routes').then(
|
||||
(m) => m.PLATFORM_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 8: Administration (legacy root retained as alias to Platform Setup)
|
||||
{
|
||||
path: 'administration',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/platform/setup',
|
||||
},
|
||||
|
||||
// Domain 9: Operations (legacy alias root retained for migration window)
|
||||
{
|
||||
path: 'operations',
|
||||
title: 'Operations',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Operations' },
|
||||
loadChildren: () =>
|
||||
import('./routes/operations.routes').then(
|
||||
(m) => m.OPERATIONS_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
// Domain 10: Administration deep-link compatibility surface
|
||||
{
|
||||
path: 'administration',
|
||||
title: 'Administration',
|
||||
@@ -123,36 +154,36 @@ export const routes: Routes = [
|
||||
// Convert to redirects and remove at SPRINT_20260218_016 after confirming traffic.
|
||||
// ========================================================================
|
||||
|
||||
// Release Control domain aliases
|
||||
// Releases domain aliases
|
||||
{
|
||||
path: 'approvals',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/approvals',
|
||||
redirectTo: '/releases/approvals',
|
||||
},
|
||||
{
|
||||
path: 'environments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/regions',
|
||||
redirectTo: '/topology/environments',
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
path: 'release-control',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/releases',
|
||||
redirectTo: '/releases',
|
||||
},
|
||||
{
|
||||
path: 'deployments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/deployments',
|
||||
redirectTo: '/releases/activity',
|
||||
},
|
||||
|
||||
// Security & Risk domain alias
|
||||
// Legacy Security alias
|
||||
{
|
||||
path: 'security',
|
||||
path: 'security-risk',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/security-risk',
|
||||
redirectTo: '/security',
|
||||
},
|
||||
|
||||
// Analytics alias (served under security-risk in v2)
|
||||
// Analytics alias (served under Security in v3)
|
||||
{
|
||||
path: 'analytics',
|
||||
title: 'Analytics',
|
||||
@@ -161,22 +192,22 @@ export const routes: Routes = [
|
||||
import('./features/analytics/analytics.routes').then((m) => m.ANALYTICS_ROUTES),
|
||||
},
|
||||
|
||||
// Evidence and Audit domain alias
|
||||
// Legacy Evidence alias
|
||||
{
|
||||
path: 'evidence',
|
||||
path: 'evidence-audit',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/evidence-audit',
|
||||
redirectTo: '/evidence',
|
||||
},
|
||||
|
||||
// Platform Ops domain alias
|
||||
// Legacy Operations aliases
|
||||
{
|
||||
path: 'operations',
|
||||
title: 'Platform Ops',
|
||||
path: 'platform-ops',
|
||||
title: 'Operations',
|
||||
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
|
||||
data: { breadcrumb: 'Platform Ops' },
|
||||
data: { breadcrumb: 'Operations' },
|
||||
loadChildren: () =>
|
||||
import('./routes/platform-ops.routes').then(
|
||||
(m) => m.PLATFORM_OPS_ROUTES
|
||||
import('./routes/operations.routes').then(
|
||||
(m) => m.OPERATIONS_ROUTES
|
||||
),
|
||||
},
|
||||
|
||||
@@ -191,27 +222,27 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'settings/release-control',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/setup',
|
||||
redirectTo: '/topology',
|
||||
},
|
||||
{
|
||||
path: 'settings/release-control/environments',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/setup/environments-paths',
|
||||
redirectTo: '/topology/environments',
|
||||
},
|
||||
{
|
||||
path: 'settings/release-control/targets',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/setup/targets-agents',
|
||||
redirectTo: '/topology/targets',
|
||||
},
|
||||
{
|
||||
path: 'settings/release-control/agents',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/setup/targets-agents',
|
||||
redirectTo: '/topology/agents',
|
||||
},
|
||||
{
|
||||
path: 'settings/release-control/workflows',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/release-control/setup/workflows',
|
||||
redirectTo: '/topology/workflows',
|
||||
},
|
||||
|
||||
// Administration domain alias — settings
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { Injectable, InjectionToken, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of, delay } from 'rxjs';
|
||||
import { Observable, catchError, delay, map, of } from 'rxjs';
|
||||
import type {
|
||||
ApprovalRequest,
|
||||
ApprovalDetail,
|
||||
@@ -32,18 +32,33 @@ export interface ApprovalApi {
|
||||
@Injectable()
|
||||
export class ApprovalHttpClient implements ApprovalApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly baseUrl = '/api/release-orchestrator/approvals';
|
||||
private readonly queueBaseUrl = '/api/v2/releases/approvals';
|
||||
private readonly detailBaseUrl = '/api/v1/approvals';
|
||||
private readonly legacyBaseUrl = '/api/release-orchestrator/approvals';
|
||||
|
||||
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
|
||||
if (filter?.urgencies?.length || (filter?.statuses?.length ?? 0) > 1) {
|
||||
return this.listApprovalsLegacy(filter);
|
||||
}
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
|
||||
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
|
||||
if (filter?.statuses?.length) params['status'] = filter.statuses[0];
|
||||
if (filter?.environment) params['environment'] = filter.environment;
|
||||
return this.http.get<ApprovalRequest[]>(this.baseUrl, { params });
|
||||
|
||||
return this.http.get<any>(this.queueBaseUrl, { params }).pipe(
|
||||
map((rows) => {
|
||||
const items = Array.isArray(rows) ? rows : (rows?.items ?? []);
|
||||
return items.map((row: any) => this.mapV2ApprovalSummary(row));
|
||||
}),
|
||||
catchError(() => this.listApprovalsLegacy(filter))
|
||||
);
|
||||
}
|
||||
|
||||
getApproval(id: string): Observable<ApprovalDetail> {
|
||||
return this.http.get<ApprovalDetail>(`${this.baseUrl}/${id}`);
|
||||
return this.http.get<any>(`${this.detailBaseUrl}/${id}`).pipe(
|
||||
map(row => this.mapV2ApprovalDetail(row)),
|
||||
catchError(() => this.http.get<ApprovalDetail>(`${this.legacyBaseUrl}/${id}`))
|
||||
);
|
||||
}
|
||||
|
||||
getPromotionPreview(releaseId: string, targetEnvironmentId: string): Observable<PromotionPreview> {
|
||||
@@ -67,19 +82,97 @@ export class ApprovalHttpClient implements ApprovalApi {
|
||||
}
|
||||
|
||||
approve(id: string, comment: string): Observable<ApprovalDetail> {
|
||||
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/approve`, { comment });
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
|
||||
action: 'approve',
|
||||
comment,
|
||||
actor: 'ui-operator',
|
||||
}).pipe(
|
||||
map(row => this.mapV2ApprovalDetail(row)),
|
||||
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/approve`, { comment }))
|
||||
);
|
||||
}
|
||||
|
||||
reject(id: string, comment: string): Observable<ApprovalDetail> {
|
||||
return this.http.post<ApprovalDetail>(`${this.baseUrl}/${id}/reject`, { comment });
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
|
||||
action: 'reject',
|
||||
comment,
|
||||
actor: 'ui-operator',
|
||||
}).pipe(
|
||||
map(row => this.mapV2ApprovalDetail(row)),
|
||||
catchError(() => this.http.post<ApprovalDetail>(`${this.legacyBaseUrl}/${id}/reject`, { comment }))
|
||||
);
|
||||
}
|
||||
|
||||
batchApprove(ids: string[], comment: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/batch-approve`, { ids, comment });
|
||||
return this.http.post<void>(`${this.legacyBaseUrl}/batch-approve`, { ids, comment });
|
||||
}
|
||||
|
||||
batchReject(ids: string[], comment: string): Observable<void> {
|
||||
return this.http.post<void>(`${this.baseUrl}/batch-reject`, { ids, comment });
|
||||
return this.http.post<void>(`${this.legacyBaseUrl}/batch-reject`, { ids, comment });
|
||||
}
|
||||
|
||||
private listApprovalsLegacy(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
|
||||
const params: Record<string, string> = {};
|
||||
if (filter?.statuses?.length) params['statuses'] = filter.statuses.join(',');
|
||||
if (filter?.urgencies?.length) params['urgencies'] = filter.urgencies.join(',');
|
||||
if (filter?.environment) params['environment'] = filter.environment;
|
||||
return this.http.get<ApprovalRequest[]>(this.legacyBaseUrl, { params });
|
||||
}
|
||||
|
||||
private mapV2ApprovalSummary(row: any): ApprovalRequest {
|
||||
return {
|
||||
id: row.approvalId ?? row.id,
|
||||
releaseId: row.releaseId,
|
||||
releaseName: row.releaseName,
|
||||
releaseVersion: row.releaseVersion ?? row.releaseName,
|
||||
sourceEnvironment: row.sourceEnvironment,
|
||||
targetEnvironment: row.targetEnvironment,
|
||||
requestedBy: row.requestedBy,
|
||||
requestedAt: row.requestedAt,
|
||||
urgency: row.urgency ?? 'normal',
|
||||
justification: row.justification ?? '',
|
||||
status: row.status ?? 'pending',
|
||||
currentApprovals: row.currentApprovals ?? 0,
|
||||
requiredApprovals: row.requiredApprovals ?? 0,
|
||||
gatesPassed: row.gatesPassed ?? ((row.blockers?.length ?? 0) === 0),
|
||||
scheduledTime: row.scheduledTime ?? null,
|
||||
expiresAt: row.expiresAt ?? row.requestedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private mapV2ApprovalDetail(row: any): ApprovalDetail {
|
||||
return {
|
||||
...this.mapV2ApprovalSummary(row),
|
||||
gateResults: (row.gateResults ?? []).map((gate: any) => ({
|
||||
gateId: gate.gateId,
|
||||
gateName: gate.gateName,
|
||||
type: gate.type,
|
||||
status: gate.status,
|
||||
message: gate.message,
|
||||
details: gate.details ?? {},
|
||||
evaluatedAt: gate.evaluatedAt ?? '',
|
||||
})),
|
||||
actions: (row.actions ?? []).map((action: any) => ({
|
||||
id: action.id,
|
||||
approvalId: action.approvalId,
|
||||
action: action.action,
|
||||
actor: action.actor,
|
||||
comment: action.comment,
|
||||
timestamp: action.timestamp,
|
||||
})),
|
||||
approvers: (row.approvers ?? []).map((approver: any) => ({
|
||||
id: approver.id,
|
||||
name: approver.name,
|
||||
email: approver.email,
|
||||
hasApproved: approver.hasApproved,
|
||||
approvedAt: approver.approvedAt ?? null,
|
||||
})),
|
||||
releaseComponents: (row.releaseComponents ?? []).map((component: any) => ({
|
||||
name: component.name,
|
||||
version: component.version,
|
||||
digest: component.digest,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,28 @@
|
||||
* Sprint: SPRINT_20260110_111_003_FE_release_management_ui
|
||||
*/
|
||||
|
||||
export type ReleaseWorkflowStatus = 'draft' | 'ready' | 'deploying' | 'deployed' | 'failed' | 'rolled_back';
|
||||
export type ReleaseWorkflowStatus =
|
||||
| 'draft'
|
||||
| 'ready'
|
||||
| 'deploying'
|
||||
| 'deployed'
|
||||
| 'failed'
|
||||
| 'rolled_back';
|
||||
|
||||
export type ReleaseType = 'standard' | 'hotfix';
|
||||
export type ReleaseGateStatus = 'pass' | 'warn' | 'block' | 'pending' | 'unknown';
|
||||
export type ReleaseRiskTier = 'critical' | 'high' | 'medium' | 'low' | 'none' | 'unknown';
|
||||
export type ReleaseEvidencePosture = 'verified' | 'partial' | 'missing' | 'replay_mismatch' | 'unknown';
|
||||
|
||||
export type ComponentType = 'container' | 'helm' | 'script';
|
||||
export type ReleaseEventType = 'created' | 'promoted' | 'approved' | 'rejected' | 'deployed' | 'failed' | 'rolled_back';
|
||||
export type ReleaseEventType =
|
||||
| 'created'
|
||||
| 'promoted'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'deployed'
|
||||
| 'failed'
|
||||
| 'rolled_back';
|
||||
export type DeploymentStrategy = 'rolling' | 'blue_green' | 'canary' | 'recreate';
|
||||
|
||||
export interface ManagedRelease {
|
||||
@@ -14,12 +33,31 @@ export interface ManagedRelease {
|
||||
version: string;
|
||||
description: string;
|
||||
status: ReleaseWorkflowStatus;
|
||||
releaseType: ReleaseType | string;
|
||||
slug: string;
|
||||
digest: string | null;
|
||||
currentStage: string | null;
|
||||
currentEnvironment: string | null;
|
||||
targetEnvironment: string | null;
|
||||
targetRegion: string | null;
|
||||
componentCount: number;
|
||||
gateStatus: ReleaseGateStatus;
|
||||
gateBlockingCount: number;
|
||||
gatePendingApprovals: number;
|
||||
gateBlockingReasons: string[];
|
||||
riskCriticalReachable: number;
|
||||
riskHighReachable: number;
|
||||
riskTrend: string;
|
||||
riskTier: ReleaseRiskTier;
|
||||
evidencePosture: ReleaseEvidencePosture;
|
||||
needsApproval: boolean;
|
||||
blocked: boolean;
|
||||
hotfixLane: boolean;
|
||||
replayMismatch: boolean;
|
||||
createdAt: string;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
lastActor: string;
|
||||
deployedAt: string | null;
|
||||
deploymentStrategy: DeploymentStrategy;
|
||||
}
|
||||
@@ -84,7 +122,16 @@ export interface AddComponentRequest {
|
||||
export interface ReleaseFilter {
|
||||
search?: string;
|
||||
statuses?: ReleaseWorkflowStatus[];
|
||||
stages?: string[];
|
||||
types?: string[];
|
||||
gateStatuses?: ReleaseGateStatus[];
|
||||
riskTiers?: ReleaseRiskTier[];
|
||||
blocked?: boolean;
|
||||
needsApproval?: boolean;
|
||||
hotfixLane?: boolean;
|
||||
replayMismatch?: boolean;
|
||||
environment?: string;
|
||||
region?: string;
|
||||
sortField?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
@@ -123,6 +170,43 @@ export function getStatusColor(status: ReleaseWorkflowStatus): string {
|
||||
return colors[status] || 'var(--color-text-secondary)';
|
||||
}
|
||||
|
||||
export function getGateStatusLabel(status: ReleaseGateStatus): string {
|
||||
const labels: Record<ReleaseGateStatus, string> = {
|
||||
pass: 'Pass',
|
||||
warn: 'Warn',
|
||||
block: 'Block',
|
||||
pending: 'Pending',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
return labels[status] ?? 'Unknown';
|
||||
}
|
||||
|
||||
export function getRiskTierLabel(tier: ReleaseRiskTier): string {
|
||||
const labels: Record<ReleaseRiskTier, string> = {
|
||||
critical: 'Critical',
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
none: 'None',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
return labels[tier] ?? 'Unknown';
|
||||
}
|
||||
|
||||
export function getEvidencePostureLabel(posture: ReleaseEvidencePosture): string {
|
||||
const labels: Record<ReleaseEvidencePosture, string> = {
|
||||
verified: 'Verified',
|
||||
partial: 'Partial',
|
||||
missing: 'Missing',
|
||||
replay_mismatch: 'Replay Mismatch',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
|
||||
return labels[posture] ?? 'Unknown';
|
||||
}
|
||||
|
||||
export function getEventIcon(type: ReleaseEventType): string {
|
||||
const icons: Record<ReleaseEventType, string> = {
|
||||
created: '+',
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
import { Injectable, InjectionToken, Inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay, map } from 'rxjs/operators';
|
||||
import { catchError, delay, map } from 'rxjs/operators';
|
||||
import { AuthSessionStore } from '../auth/auth-session.store';
|
||||
import { PlatformContextStore } from '../context/platform-context.store';
|
||||
|
||||
// ============================================================================
|
||||
// Models
|
||||
@@ -59,12 +60,34 @@ export const SECURITY_FINDINGS_API_BASE_URL = new InjectionToken<string>('SECURI
|
||||
// HTTP Implementation
|
||||
// ============================================================================
|
||||
|
||||
interface SecurityFindingProjectionDto {
|
||||
findingId: string;
|
||||
cveId: string;
|
||||
severity: string;
|
||||
packageName: string;
|
||||
componentName: string;
|
||||
releaseId: string;
|
||||
releaseName: string;
|
||||
environment: string;
|
||||
region: string;
|
||||
reachable: boolean;
|
||||
reachabilityScore: number;
|
||||
effectiveDisposition: string;
|
||||
vexStatus: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SecurityFindingsResponseDto {
|
||||
items: SecurityFindingProjectionDto[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SecurityFindingsHttpClient implements SecurityFindingsApi {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@Inject(SECURITY_FINDINGS_API_BASE_URL) private readonly baseUrl: string,
|
||||
private readonly authSession: AuthSessionStore,
|
||||
private readonly context: PlatformContextStore,
|
||||
) {}
|
||||
|
||||
listFindings(filter?: FindingsFilter): Observable<FindingDto[]> {
|
||||
@@ -74,18 +97,48 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
|
||||
if (filter?.environment) params = params.set('environment', filter.environment);
|
||||
if (filter?.limit) params = params.set('limit', filter.limit.toString());
|
||||
if (filter?.sort) params = params.set('sort', filter.sort);
|
||||
return this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
|
||||
params,
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((res: any) => Array.isArray(res) ? res : (res?.items ?? [])),
|
||||
);
|
||||
const selectedRegion = this.context.selectedRegions()[0];
|
||||
if (selectedRegion) {
|
||||
params = params.set('region', selectedRegion);
|
||||
}
|
||||
if (!filter?.environment) {
|
||||
const selectedEnvironment = this.context.selectedEnvironments()[0];
|
||||
if (selectedEnvironment) {
|
||||
params = params.set('environment', selectedEnvironment);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<SecurityFindingsResponseDto>(`${this.baseUrl}/api/v2/security/findings`, {
|
||||
params,
|
||||
headers: this.buildHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((res) => (res?.items ?? []).map((row) => this.mapV2Finding(row))),
|
||||
catchError(() =>
|
||||
this.http.get<any>(`${this.baseUrl}/api/v1/findings/summaries`, {
|
||||
params,
|
||||
headers: this.buildHeaders(),
|
||||
}).pipe(
|
||||
map((res: any) => (Array.isArray(res) ? res : (res?.items ?? []))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getFinding(findingId: string): Observable<FindingDetailDto> {
|
||||
return this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
|
||||
headers: this.buildHeaders(),
|
||||
});
|
||||
return this.http
|
||||
.get<any>(`${this.baseUrl}/api/v2/security/disposition/${findingId}`, {
|
||||
headers: this.buildHeaders(),
|
||||
})
|
||||
.pipe(
|
||||
map((res) => this.mapDispositionToDetail(res?.item ?? res, findingId)),
|
||||
catchError(() =>
|
||||
this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}/summary`, {
|
||||
headers: this.buildHeaders(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private buildHeaders(): HttpHeaders {
|
||||
@@ -96,6 +149,59 @@ export class SecurityFindingsHttpClient implements SecurityFindingsApi {
|
||||
}
|
||||
return new HttpHeaders(headers);
|
||||
}
|
||||
|
||||
private mapV2Finding(row: SecurityFindingProjectionDto): FindingDto {
|
||||
return {
|
||||
id: row.findingId,
|
||||
package: row.packageName,
|
||||
version: row.componentName || 'n/a',
|
||||
severity: this.mapSeverity(row.severity),
|
||||
cvss: Math.round((Math.max(0, row.reachabilityScore ?? 0) / 10) * 10) / 10,
|
||||
reachable: row.reachable,
|
||||
reachabilityConfidence: row.reachabilityScore,
|
||||
vexStatus: row.vexStatus || row.effectiveDisposition || 'none',
|
||||
releaseId: row.releaseId,
|
||||
releaseVersion: row.releaseName,
|
||||
delta: 'carried',
|
||||
environments: row.environment ? [row.environment] : [],
|
||||
firstSeen: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapDispositionToDetail(row: any, fallbackId: string): FindingDetailDto {
|
||||
const base = this.mapV2Finding({
|
||||
findingId: row?.findingId ?? fallbackId,
|
||||
cveId: row?.cveId ?? fallbackId,
|
||||
severity: 'medium',
|
||||
packageName: row?.packageName ?? 'unknown',
|
||||
componentName: row?.componentName ?? 'unknown',
|
||||
releaseId: row?.releaseId ?? '',
|
||||
releaseName: row?.releaseName ?? '',
|
||||
environment: row?.environment ?? '',
|
||||
region: row?.region ?? '',
|
||||
reachable: true,
|
||||
reachabilityScore: 0,
|
||||
effectiveDisposition: row?.effectiveDisposition ?? 'unknown',
|
||||
vexStatus: row?.vex?.status ?? row?.effectiveDisposition ?? 'none',
|
||||
updatedAt: row?.updatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
...base,
|
||||
description: `Disposition: ${row?.effectiveDisposition ?? 'unknown'}`,
|
||||
references: [],
|
||||
affectedVersions: [],
|
||||
fixedVersions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private mapSeverity(value: string): FindingDto['severity'] {
|
||||
const normalized = (value ?? '').toUpperCase();
|
||||
if (normalized === 'CRITICAL' || normalized === 'HIGH' || normalized === 'MEDIUM' || normalized === 'LOW') {
|
||||
return normalized;
|
||||
}
|
||||
return 'MEDIUM';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from './platform-context.store';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalContextHttpInterceptor implements HttpInterceptor {
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
if (!this.isPack22ContextAwareRoute(request.url)) {
|
||||
return next.handle(request);
|
||||
}
|
||||
|
||||
let params = request.params;
|
||||
const region = this.context.selectedRegions()[0];
|
||||
const environment = this.context.selectedEnvironments()[0];
|
||||
const timeWindow = this.context.timeWindow();
|
||||
|
||||
if (region && !params.has('region')) {
|
||||
params = params.set('region', region);
|
||||
}
|
||||
if (environment && !params.has('environment')) {
|
||||
params = params.set('environment', environment);
|
||||
}
|
||||
if (timeWindow && !params.has('timeWindow')) {
|
||||
params = params.set('timeWindow', timeWindow);
|
||||
}
|
||||
|
||||
return next.handle(request.clone({ params }));
|
||||
}
|
||||
|
||||
private isPack22ContextAwareRoute(url: string): boolean {
|
||||
return (
|
||||
url.includes('/api/v2/releases') ||
|
||||
url.includes('/api/v2/security') ||
|
||||
url.includes('/api/v2/evidence') ||
|
||||
url.includes('/api/v2/topology') ||
|
||||
url.includes('/api/v2/integrations')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
export interface PlatformContextRegion {
|
||||
regionId: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformContextEnvironment {
|
||||
environmentId: string;
|
||||
regionId: string;
|
||||
environmentType: string;
|
||||
displayName: string;
|
||||
sortOrder: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformContextPreferences {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
regions: string[];
|
||||
environments: string[];
|
||||
timeWindow: string;
|
||||
updatedAt: string;
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TIME_WINDOW = '24h';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformContextStore {
|
||||
private readonly http = inject(HttpClient);
|
||||
private persistPaused = false;
|
||||
private readonly apiDisabled = this.shouldDisableApiCalls();
|
||||
|
||||
readonly regions = signal<PlatformContextRegion[]>([]);
|
||||
readonly environments = signal<PlatformContextEnvironment[]>([]);
|
||||
readonly selectedRegions = signal<string[]>([]);
|
||||
readonly selectedEnvironments = signal<string[]>([]);
|
||||
readonly timeWindow = signal(DEFAULT_TIME_WINDOW);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly initialized = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Incremented on context updates so route-level stores can trigger refetch.
|
||||
readonly contextVersion = signal(0);
|
||||
|
||||
readonly regionSummary = computed(() => {
|
||||
const selected = this.selectedRegions();
|
||||
if (selected.length === 0) {
|
||||
return 'All regions';
|
||||
}
|
||||
|
||||
if (selected.length === 1) {
|
||||
const region = this.regions().find((item) => item.regionId === selected[0]);
|
||||
return region?.displayName ?? selected[0];
|
||||
}
|
||||
|
||||
return `${selected.length} regions`;
|
||||
});
|
||||
|
||||
readonly environmentSummary = computed(() => {
|
||||
const selected = this.selectedEnvironments();
|
||||
if (selected.length === 0) {
|
||||
return 'All environments';
|
||||
}
|
||||
|
||||
if (selected.length === 1) {
|
||||
const env = this.environments().find((item) => item.environmentId === selected[0]);
|
||||
return env?.displayName ?? selected[0];
|
||||
}
|
||||
|
||||
return `${selected.length} environments`;
|
||||
});
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized() || this.loading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.apiDisabled) {
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
this.initialized.set(true);
|
||||
this.persistPaused = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.persistPaused = true;
|
||||
|
||||
this.http
|
||||
.get<PlatformContextRegion[]>('/api/v2/context/regions')
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (regions) => {
|
||||
const sortedRegions = [...(regions ?? [])].sort((a, b) => {
|
||||
if (a.sortOrder !== b.sortOrder) {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
|
||||
});
|
||||
this.regions.set(sortedRegions);
|
||||
this.loadPreferences();
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.normalizeError(err, 'Failed to load global regions.'));
|
||||
this.loading.set(false);
|
||||
this.persistPaused = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setRegions(regionIds: string[]): void {
|
||||
const next = this.normalizeIds(regionIds, this.regions().map((item) => item.regionId));
|
||||
if (this.arraysEqual(next, this.selectedRegions())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedRegions.set(next);
|
||||
this.loadEnvironments(next, this.selectedEnvironments(), true);
|
||||
}
|
||||
|
||||
setEnvironments(environmentIds: string[]): void {
|
||||
const next = this.normalizeIds(
|
||||
environmentIds,
|
||||
this.environments().map((item) => item.environmentId),
|
||||
);
|
||||
|
||||
if (this.arraysEqual(next, this.selectedEnvironments())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedEnvironments.set(next);
|
||||
this.persistPreferences();
|
||||
this.bumpContextVersion();
|
||||
}
|
||||
|
||||
setTimeWindow(timeWindow: string): void {
|
||||
const normalized = (timeWindow || DEFAULT_TIME_WINDOW).trim();
|
||||
if (normalized === this.timeWindow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timeWindow.set(normalized);
|
||||
this.persistPreferences();
|
||||
this.bumpContextVersion();
|
||||
}
|
||||
|
||||
private loadPreferences(): void {
|
||||
this.http
|
||||
.get<PlatformContextPreferences>('/api/v2/context/preferences')
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (prefs) => {
|
||||
const preferredRegions = this.normalizeIds(
|
||||
prefs?.regions ?? [],
|
||||
this.regions().map((item) => item.regionId),
|
||||
);
|
||||
this.selectedRegions.set(preferredRegions);
|
||||
this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW);
|
||||
this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false);
|
||||
},
|
||||
error: () => {
|
||||
// Preferences are optional; continue with default empty context.
|
||||
this.selectedRegions.set([]);
|
||||
this.selectedEnvironments.set([]);
|
||||
this.timeWindow.set(DEFAULT_TIME_WINDOW);
|
||||
this.loadEnvironments([], [], false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private loadEnvironments(
|
||||
regionIds: string[],
|
||||
preferredEnvironmentIds: string[],
|
||||
persistAfterLoad: boolean,
|
||||
): void {
|
||||
let params = new HttpParams();
|
||||
if (regionIds.length > 0) {
|
||||
params = params.set('regions', regionIds.join(','));
|
||||
}
|
||||
|
||||
this.http
|
||||
.get<PlatformContextEnvironment[]>('/api/v2/context/environments', { params })
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
next: (environments) => {
|
||||
const sortedEnvironments = [...(environments ?? [])].sort((a, b) => {
|
||||
if (a.sortOrder !== b.sortOrder) {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
if (a.regionId !== b.regionId) {
|
||||
return a.regionId.localeCompare(b.regionId, 'en', { sensitivity: 'base' });
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName, 'en', { sensitivity: 'base' });
|
||||
});
|
||||
|
||||
this.environments.set(sortedEnvironments);
|
||||
const nextEnvironments = this.normalizeIds(
|
||||
preferredEnvironmentIds,
|
||||
sortedEnvironments.map((item) => item.environmentId),
|
||||
);
|
||||
this.selectedEnvironments.set(nextEnvironments);
|
||||
|
||||
if (persistAfterLoad) {
|
||||
this.persistPreferences();
|
||||
}
|
||||
|
||||
this.finishInitialization();
|
||||
this.bumpContextVersion();
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.normalizeError(err, 'Failed to load global environments.'));
|
||||
this.environments.set([]);
|
||||
this.selectedEnvironments.set([]);
|
||||
|
||||
if (persistAfterLoad) {
|
||||
this.persistPreferences();
|
||||
}
|
||||
|
||||
this.finishInitialization();
|
||||
this.bumpContextVersion();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private persistPreferences(): void {
|
||||
if (this.persistPaused || this.apiDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
regions: this.selectedRegions(),
|
||||
environments: this.selectedEnvironments(),
|
||||
timeWindow: this.timeWindow(),
|
||||
};
|
||||
|
||||
this.http
|
||||
.put<PlatformContextPreferences>('/api/v2/context/preferences', payload)
|
||||
.pipe(take(1))
|
||||
.subscribe({
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.normalizeError(err, 'Failed to persist global context preferences.'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private finishInitialization(): void {
|
||||
this.loading.set(false);
|
||||
this.initialized.set(true);
|
||||
this.persistPaused = false;
|
||||
}
|
||||
|
||||
private normalizeIds(values: string[], allowedValues: string[]): string[] {
|
||||
const allowed = new Set(allowedValues.map((value) => value.toLowerCase()));
|
||||
const deduped = new Map<string, string>();
|
||||
|
||||
for (const raw of values ?? []) {
|
||||
const trimmed = (raw ?? '').trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (!allowed.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deduped.has(normalized)) {
|
||||
deduped.set(normalized, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
private arraysEqual(left: string[], right: string[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (left[i] !== right[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private bumpContextVersion(): void {
|
||||
this.contextVersion.update((value) => value + 1);
|
||||
}
|
||||
|
||||
private shouldDisableApiCalls(): boolean {
|
||||
const userAgent = (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent ?? '';
|
||||
if (userAgent.toLowerCase().includes('jsdom')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const protocol = (globalThis as { location?: { protocol?: string } }).location?.protocol ?? '';
|
||||
return protocol === 'about:';
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,44 @@ import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
|
||||
* Used to detect when a route was accessed via legacy URL.
|
||||
*/
|
||||
const LEGACY_ROUTE_MAP: Record<string, string> = {
|
||||
// Pack 22 root migration aliases
|
||||
'release-control': '/releases',
|
||||
'release-control/releases': '/releases',
|
||||
'release-control/approvals': '/releases/approvals',
|
||||
'release-control/runs': '/releases/activity',
|
||||
'release-control/deployments': '/releases/activity',
|
||||
'release-control/promotions': '/releases/activity',
|
||||
'release-control/hotfixes': '/releases',
|
||||
'release-control/regions': '/topology/regions',
|
||||
'release-control/setup': '/topology',
|
||||
|
||||
'security-risk': '/security',
|
||||
'security-risk/findings': '/security/findings',
|
||||
'security-risk/vulnerabilities': '/security/vulnerabilities',
|
||||
'security-risk/disposition': '/security/disposition',
|
||||
'security-risk/sbom': '/security/sbom-explorer/graph',
|
||||
'security-risk/sbom-lake': '/security/sbom-explorer/table',
|
||||
'security-risk/vex': '/security/disposition',
|
||||
'security-risk/exceptions': '/security/disposition',
|
||||
'security-risk/advisory-sources': '/integrations/feeds',
|
||||
|
||||
'evidence-audit': '/evidence',
|
||||
'evidence-audit/packs': '/evidence/packs',
|
||||
'evidence-audit/bundles': '/evidence/bundles',
|
||||
'evidence-audit/evidence': '/evidence/evidence',
|
||||
'evidence-audit/proofs': '/evidence/proofs',
|
||||
'evidence-audit/audit-log': '/evidence/audit-log',
|
||||
'evidence-audit/replay': '/evidence/replay',
|
||||
|
||||
'platform-ops': '/operations',
|
||||
'platform-ops/data-integrity': '/operations/data-integrity',
|
||||
'platform-ops/orchestrator': '/operations/orchestrator',
|
||||
'platform-ops/health': '/operations/health',
|
||||
'platform-ops/quotas': '/operations/quotas',
|
||||
'platform-ops/feeds': '/operations/feeds',
|
||||
'platform-ops/offline-kit': '/operations/offline-kit',
|
||||
'platform-ops/agents': '/topology/agents',
|
||||
|
||||
// Home & Dashboard
|
||||
'dashboard/sources': '/operations/feeds',
|
||||
'home': '/',
|
||||
@@ -104,6 +142,12 @@ const LEGACY_ROUTE_MAP: Record<string, string> = {
|
||||
* These use regex to match dynamic segments.
|
||||
*/
|
||||
const LEGACY_ROUTE_PATTERNS: Array<{ pattern: RegExp; oldPrefix: string; newPrefix: string }> = [
|
||||
{ pattern: /^release-control\/releases\/([^/]+)$/, oldPrefix: 'release-control/releases/', newPrefix: '/releases/' },
|
||||
{ pattern: /^release-control\/approvals\/([^/]+)$/, oldPrefix: 'release-control/approvals/', newPrefix: '/releases/approvals/' },
|
||||
{ pattern: /^security-risk\/findings\/([^/]+)$/, oldPrefix: 'security-risk/findings/', newPrefix: '/security/findings/' },
|
||||
{ pattern: /^security-risk\/vulnerabilities\/([^/]+)$/, oldPrefix: 'security-risk/vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
|
||||
{ pattern: /^evidence-audit\/packs\/([^/]+)$/, oldPrefix: 'evidence-audit/packs/', newPrefix: '/evidence/packs/' },
|
||||
|
||||
// Scan/finding details
|
||||
{ pattern: /^findings\/([^/]+)$/, oldPrefix: 'findings/', newPrefix: '/security/scans/' },
|
||||
{ pattern: /^scans\/([^/]+)$/, oldPrefix: 'scans/', newPrefix: '/security/scans/' },
|
||||
|
||||
@@ -94,7 +94,7 @@ interface HistoryEvent {
|
||||
template: `
|
||||
<div class="approval-detail-v2">
|
||||
<header class="decision-header">
|
||||
<a routerLink="/release-control/approvals" class="back-link">Back to Approvals</a>
|
||||
<a routerLink="/releases/approvals" class="back-link">Back to Approvals</a>
|
||||
|
||||
<div class="decision-header__title-row">
|
||||
<h1>Approval Detail</h1>
|
||||
@@ -337,7 +337,7 @@ interface HistoryEvent {
|
||||
|
||||
<div class="footer-links">
|
||||
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Open Reachability Ingest Health</a>
|
||||
<a routerLink="/release-control/environments">Open Env Detail</a>
|
||||
<a routerLink="/topology/environments">Open Env Detail</a>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -1,618 +1,220 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
|
||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
|
||||
|
||||
type DataIntegrityStatus = 'OK' | 'WARN' | 'FAIL';
|
||||
type QueueTab = 'pending' | 'approved' | 'rejected' | 'expiring' | 'my-team';
|
||||
|
||||
/**
|
||||
* ApprovalsInboxComponent - Approval decision cockpit.
|
||||
* Wired to real APPROVAL_API for live data.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-approvals-inbox',
|
||||
imports: [CommonModule, RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="approvals">
|
||||
<header class="approvals__header">
|
||||
<div>
|
||||
<h1 class="approvals__title">Approvals</h1>
|
||||
<p class="approvals__subtitle">
|
||||
Decide promotions with policy + reachability, backed by signed evidence.
|
||||
</p>
|
||||
</div>
|
||||
selector: 'app-approvals-inbox',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
template: `
|
||||
<section class="approvals">
|
||||
<header>
|
||||
<h1>Release Run Approvals Queue</h1>
|
||||
<p>Run-centric approval queue with gate/env/hotfix/risk filtering.</p>
|
||||
</header>
|
||||
|
||||
@if (dataIntegrityBannerVisible()) {
|
||||
<section class="data-integrity-banner" [class]="'data-integrity-banner data-integrity-banner--' + dataIntegrityStatus().toLowerCase()">
|
||||
<div>
|
||||
<p class="data-integrity-banner__title">
|
||||
Data Integrity {{ dataIntegrityStatus() }}
|
||||
</p>
|
||||
<p class="data-integrity-banner__detail">{{ dataIntegritySummary() }}</p>
|
||||
</div>
|
||||
<div class="data-integrity-banner__actions">
|
||||
<a routerLink="/platform-ops/data-integrity">Open Data Integrity</a>
|
||||
<button type="button" (click)="dismissDataIntegrityBanner()">Dismiss</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<nav class="tabs" aria-label="Approvals queue tabs">
|
||||
@for (tab of tabs; track tab.id) {
|
||||
<a [routerLink]="[]" [queryParams]="{ tab: tab.id }" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="approvals__filters">
|
||||
<div class="filter-group">
|
||||
<span class="filter-group__label">Status</span>
|
||||
<div class="filter-chips">
|
||||
@for (status of statusOptions; track status.value) {
|
||||
<button type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--active]="currentStatusFilter === status.value"
|
||||
(click)="onStatusChipClick(status.value)">
|
||||
{{ status.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<select [(ngModel)]="gateTypeFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Gate Type: All</option>
|
||||
<option value="policy">Policy</option>
|
||||
<option value="ops">Ops</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-group filter-group--env" [class.filter-group--visible]="currentStatusFilter !== null">
|
||||
<span class="filter-group__label">Environment</span>
|
||||
<div class="filter-chips">
|
||||
@for (env of environmentOptions; track env.value) {
|
||||
<button type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--active]="currentEnvironmentFilter === env.value"
|
||||
(click)="onEnvironmentFilter(env.value)">
|
||||
{{ env.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<select [(ngModel)]="envFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Environment: All</option>
|
||||
<option value="dev">Dev</option>
|
||||
<option value="qa">QA</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="prod">Prod</option>
|
||||
</select>
|
||||
|
||||
<div class="filter-search-wrapper">
|
||||
<svg class="filter-search-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="text" class="filter-search" placeholder="Search approvals..." [(ngModel)]="searchQuery" (ngModelChange)="onSearchChange()" />
|
||||
@if (searchQuery) {
|
||||
<button type="button" class="filter-search-clear" (click)="clearSearch()">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<select [(ngModel)]="hotfixFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Hotfix: All</option>
|
||||
<option value="true">Hotfix Only</option>
|
||||
<option value="false">Non-hotfix</option>
|
||||
</select>
|
||||
|
||||
<select [(ngModel)]="riskFilter" (ngModelChange)="applyFilters()">
|
||||
<option value="all">Risk: All</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="normal">Normal/Low</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-banner">Loading approvals...</div>
|
||||
}
|
||||
@if (loading()) { <div class="banner">Loading approvals...</div> }
|
||||
@if (error()) { <div class="banner error">{{ error() }}</div> }
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-banner">{{ error() }}</div>
|
||||
}
|
||||
|
||||
<!-- Approvals list -->
|
||||
@if (!loading()) {
|
||||
<section class="approvals__section">
|
||||
<h2 class="approvals__section-title">Results ({{ approvals().length }})</h2>
|
||||
|
||||
@for (approval of approvals(); track approval.id) {
|
||||
<div class="approval-card">
|
||||
<div class="approval-card__header">
|
||||
<a [routerLink]="['/release-control/releases', approval.releaseId]" class="approval-card__release">
|
||||
{{ approval.releaseName }} v{{ approval.releaseVersion }}
|
||||
</a>
|
||||
<span class="approval-card__flow">{{ approval.sourceEnvironment }} → {{ approval.targetEnvironment }}</span>
|
||||
<span class="approval-card__meta">Requested by: {{ approval.requestedBy }} • {{ timeAgo(approval.requestedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="approval-card__changes">
|
||||
<strong>JUSTIFICATION:</strong>
|
||||
{{ approval.justification }}
|
||||
</div>
|
||||
|
||||
<div class="approval-card__gates">
|
||||
<div class="gates-row">
|
||||
<div class="gate-item" [class]="approval.gatesPassed ? 'gate-item--pass' : 'gate-item--block'">
|
||||
<span class="gate-item__badge">{{ approval.gatesPassed ? 'PASS' : 'BLOCK' }}</span>
|
||||
<span class="gate-item__name">Policy Gates</span>
|
||||
</div>
|
||||
<div class="gate-item">
|
||||
<span class="gate-item__badge">{{ approval.currentApprovals }}/{{ approval.requiredApprovals }}</span>
|
||||
<span class="gate-item__name">Approvals</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-card__actions">
|
||||
@if (approval.status === 'pending') {
|
||||
<button type="button" class="btn btn--success" (click)="approveRequest(approval.id)">Approve</button>
|
||||
<button type="button" class="btn btn--danger" (click)="rejectRequest(approval.id)">Reject</button>
|
||||
}
|
||||
<a [routerLink]="['/release-control/approvals', approval.id]" class="btn btn--secondary">View Details</a>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="empty-state">No approvals match the current filters</div>
|
||||
}
|
||||
</section>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Release</th>
|
||||
<th>Flow</th>
|
||||
<th>Gate Type</th>
|
||||
<th>Risk</th>
|
||||
<th>Status</th>
|
||||
<th>Requester</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (approval of filtered(); track approval.id) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'timeline']">{{ approval.releaseName }} {{ approval.releaseVersion }}</a></td>
|
||||
<td>{{ approval.sourceEnvironment }} ? {{ approval.targetEnvironment }}</td>
|
||||
<td>{{ deriveGateType(approval) }}</td>
|
||||
<td>{{ approval.urgency }}</td>
|
||||
<td>{{ approval.status }}</td>
|
||||
<td>{{ approval.requestedBy }}</td>
|
||||
<td>{{ timeRemaining(approval.expiresAt) }}</td>
|
||||
<td><a [routerLink]="['/releases/runs', approval.releaseId, 'approvals']" [queryParams]="{ approvalId: approval.id }">Open</a></td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="8">No approvals match the active queue filters.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.approvals {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.approvals__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.approvals__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.approvals__subtitle {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.approvals__filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-integrity-banner {
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.data-integrity-banner--warn {
|
||||
background: var(--color-status-warning-bg);
|
||||
border-color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.data-integrity-banner--fail {
|
||||
background: var(--color-status-error-bg);
|
||||
border-color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.data-integrity-banner__title {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.data-integrity-banner__detail {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.data-integrity-banner__actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-integrity-banner__actions a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.data-integrity-banner__actions button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.25rem 0.55rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--color-surface-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.filter-chip--active {
|
||||
background: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-chip--active:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
border-color: var(--color-brand-primary-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-group--env {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.25s ease, margin 0.3s ease;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-group--env.filter-group--visible {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2.25rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.filter-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.filter-search-clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-search-clear:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-banner {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--color-status-error-bg);
|
||||
border: 1px solid rgba(248, 113, 113, 0.5);
|
||||
color: var(--color-status-error);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.approvals__section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.approvals__section-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.approval-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.approval-card__release {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.approval-card__flow {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.approval-card__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.approval-card__changes {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.approval-card__gates {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gates-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gate-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-item__badge {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gate-item--pass .gate-item__badge {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.gate-item--warn .gate-item__badge {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.gate-item--block .gate-item__badge {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.approval-card__actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.btn--success {
|
||||
background: var(--color-status-success);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-status-success-text);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--color-severity-critical);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-status-error-text);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-brand-soft);
|
||||
}
|
||||
}
|
||||
styles: [`
|
||||
.approvals{display:grid;gap:.6rem}.approvals header h1{margin:0}.approvals header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
|
||||
.tabs,.filters{display:flex;gap:.3rem;flex-wrap:wrap}.tabs a{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none}
|
||||
.tabs a.active{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
|
||||
.filters select{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .45rem;font-size:.72rem}
|
||||
.banner,table{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} .banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .45rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}tr:last-child td{border-bottom:none}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ApprovalsInboxComponent implements OnInit {
|
||||
export class ApprovalsInboxComponent {
|
||||
private readonly api = inject(APPROVAL_API);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly dataIntegrityStatus = signal<DataIntegrityStatus>('WARN');
|
||||
readonly dataIntegritySummary = signal('NVD stale 3h | SBOM rescan FAILED | Runtime ingest lagging');
|
||||
readonly dataIntegrityDismissed = signal(false);
|
||||
readonly dataIntegrityBannerVisible = computed(
|
||||
() => this.dataIntegrityStatus() !== 'OK' && !this.dataIntegrityDismissed()
|
||||
);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly approvals = signal<ApprovalRequest[]>([]);
|
||||
readonly filtered = signal<ApprovalRequest[]>([]);
|
||||
|
||||
readonly statusOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
readonly activeTab = signal<QueueTab>('pending');
|
||||
readonly tabs: Array<{ id: QueueTab; label: string }> = [
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'rejected', label: 'Rejected' },
|
||||
{ id: 'expiring', label: 'Expiring' },
|
||||
{ id: 'my-team', label: 'My Team' },
|
||||
];
|
||||
|
||||
readonly environmentOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'dev', label: 'Dev' },
|
||||
{ value: 'qa', label: 'QA' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Prod' },
|
||||
];
|
||||
gateTypeFilter = 'all';
|
||||
envFilter = 'all';
|
||||
hotfixFilter = 'all';
|
||||
riskFilter = 'all';
|
||||
|
||||
currentStatusFilter: string = 'pending';
|
||||
currentEnvironmentFilter: string = '';
|
||||
searchQuery: string = '';
|
||||
constructor() {
|
||||
this.route.queryParamMap.subscribe((params) => {
|
||||
const tab = (params.get('tab') ?? 'pending') as QueueTab;
|
||||
if (this.tabs.some((item) => item.id === tab)) {
|
||||
this.activeTab.set(tab);
|
||||
} else {
|
||||
this.activeTab.set('pending');
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (sessionStorage.getItem('approvals.data-integrity-banner-dismissed') === '1') {
|
||||
this.dataIntegrityDismissed.set(true);
|
||||
this.load();
|
||||
});
|
||||
}
|
||||
|
||||
deriveGateType(approval: ApprovalRequest): 'policy' | 'ops' | 'security' {
|
||||
const releaseName = approval.releaseName.toLowerCase();
|
||||
if (!approval.gatesPassed || releaseName.includes('policy')) {
|
||||
return 'policy';
|
||||
}
|
||||
this.loadApprovals();
|
||||
if (approval.urgency === 'critical' || approval.urgency === 'high') {
|
||||
return 'security';
|
||||
}
|
||||
return 'ops';
|
||||
}
|
||||
|
||||
dismissDataIntegrityBanner(): void {
|
||||
this.dataIntegrityDismissed.set(true);
|
||||
sessionStorage.setItem('approvals.data-integrity-banner-dismissed', '1');
|
||||
applyFilters(): void {
|
||||
const tab = this.activeTab();
|
||||
const now = Date.now();
|
||||
|
||||
let rows = [...this.approvals()];
|
||||
if (tab === 'expiring') {
|
||||
rows = rows.filter((item) => item.status === 'pending' && (new Date(item.expiresAt).getTime() - now) <= 24 * 60 * 60 * 1000);
|
||||
} else if (tab === 'my-team') {
|
||||
rows = rows.filter((item) => item.status === 'pending' && item.requestedBy.toLowerCase().includes('team'));
|
||||
} else {
|
||||
rows = rows.filter((item) => item.status === tab);
|
||||
}
|
||||
|
||||
if (this.gateTypeFilter !== 'all') {
|
||||
rows = rows.filter((item) => this.deriveGateType(item) === this.gateTypeFilter);
|
||||
}
|
||||
if (this.envFilter !== 'all') {
|
||||
rows = rows.filter((item) => item.targetEnvironment.toLowerCase().includes(this.envFilter));
|
||||
}
|
||||
if (this.hotfixFilter !== 'all') {
|
||||
const hotfix = this.hotfixFilter === 'true';
|
||||
rows = rows.filter((item) => item.releaseName.toLowerCase().includes('hotfix') === hotfix);
|
||||
}
|
||||
if (this.riskFilter !== 'all') {
|
||||
if (this.riskFilter === 'normal') {
|
||||
rows = rows.filter((item) => item.urgency === 'normal' || item.urgency === 'low');
|
||||
} else {
|
||||
rows = rows.filter((item) => item.urgency === this.riskFilter);
|
||||
}
|
||||
}
|
||||
|
||||
this.filtered.set(rows.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()));
|
||||
}
|
||||
|
||||
onStatusChipClick(value: string): void {
|
||||
this.currentStatusFilter = value;
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
onEnvironmentFilter(value: string): void {
|
||||
this.currentEnvironmentFilter = value;
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
onSearchChange(): void {
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
approveRequest(id: string): void {
|
||||
// Route to the detail page so the user can provide a decision reason
|
||||
// before the action fires. The detail page has the full Decision panel.
|
||||
this.router.navigate(['/release-control/approvals', id]);
|
||||
}
|
||||
|
||||
rejectRequest(id: string): void {
|
||||
// Route to the detail page so the user can provide a rejection reason.
|
||||
this.router.navigate(['/release-control/approvals', id]);
|
||||
}
|
||||
|
||||
timeAgo(dateStr: string): string {
|
||||
const ms = Date.now() - new Date(dateStr).getTime();
|
||||
timeRemaining(expiresAt: string): string {
|
||||
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||
if (ms <= 0) return 'expired';
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
if (hours < 1) return 'just now';
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
if (hours >= 24) {
|
||||
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
||||
}
|
||||
const minutes = Math.floor((ms % 3600000) / 60000);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
private loadApprovals(): void {
|
||||
private load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const filter: any = {};
|
||||
if (this.currentStatusFilter) {
|
||||
filter.statuses = [this.currentStatusFilter];
|
||||
}
|
||||
if (this.currentEnvironmentFilter) {
|
||||
filter.environment = this.currentEnvironmentFilter;
|
||||
}
|
||||
this.api.listApprovals(filter).pipe(
|
||||
|
||||
const tab = this.activeTab();
|
||||
let statuses: ApprovalStatus[] | undefined;
|
||||
if (tab === 'approved') statuses = ['approved'];
|
||||
else if (tab === 'rejected') statuses = ['rejected'];
|
||||
else statuses = ['pending'];
|
||||
|
||||
this.api.listApprovals({ statuses }).pipe(
|
||||
catchError(() => {
|
||||
this.error.set('Failed to load approvals. The backend may be unavailable.');
|
||||
return of([]);
|
||||
})
|
||||
).subscribe(approvals => {
|
||||
this.approvals.set(approvals);
|
||||
this.error.set('Failed to load approvals queue.');
|
||||
return of([] as ApprovalRequest[]);
|
||||
}),
|
||||
).subscribe((rows) => {
|
||||
this.approvals.set(rows);
|
||||
this.applyFilters();
|
||||
this.loading.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Approvals Routes — Decision Cockpit
|
||||
* Updated: SPRINT_20260218_011_FE_ui_v2_rewire_approvals_decision_cockpit (A6-01 through A6-05)
|
||||
*
|
||||
* Canonical approval surfaces under /release-control/approvals:
|
||||
* Canonical approval surfaces under /releases/approvals:
|
||||
* '' — Approvals queue (A6-01)
|
||||
* :id — Decision cockpit with full operational context (A6-02 through A6-04):
|
||||
* Overview, Gates, Security, Reachability, Ops/Data, Evidence, Replay/Verify, History
|
||||
@@ -35,6 +35,8 @@ export const APPROVALS_ROUTES: Routes = [
|
||||
decisionTabs: ['overview', 'gates', 'security', 'reachability', 'ops-data', 'evidence', 'replay', 'history'],
|
||||
},
|
||||
loadComponent: () =>
|
||||
import('./approval-detail-page.component').then((m) => m.ApprovalDetailPageComponent),
|
||||
import('../release-orchestrator/approvals/approval-detail/approval-detail.component').then(
|
||||
(m) => m.ApprovalDetailComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
* Approval Detail Store
|
||||
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-009)
|
||||
*
|
||||
* Signal-based state management for the approval detail page.
|
||||
* Handles approval data, gate results, witness data, comments, and decision actions.
|
||||
* API-backed state management for approval detail workflows.
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
|
||||
// === Interfaces ===
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { catchError, forkJoin, of } from 'rxjs';
|
||||
|
||||
export interface Approval {
|
||||
id: string;
|
||||
@@ -93,14 +91,35 @@ export interface SecurityDiffEntry {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
// === Store ===
|
||||
interface ApprovalV2Dto {
|
||||
id: string;
|
||||
releaseId: string;
|
||||
releaseVersion: string;
|
||||
sourceEnvironment: string;
|
||||
targetEnvironment: string;
|
||||
status: string;
|
||||
requestedBy: string;
|
||||
requestedAt: string;
|
||||
expiresAt?: string;
|
||||
releaseComponents?: Array<{ name: string; version: string; digest: string }>;
|
||||
gateResults?: Array<{ gateId: string; gateName: string; status: string; message?: string }>;
|
||||
actions?: Array<{ id: string; action: string; actor: string; comment: string; timestamp: string }>;
|
||||
manifestDigest?: string;
|
||||
}
|
||||
|
||||
interface ApprovalGatesResponse {
|
||||
gates?: Array<{ gateId: string; gateName: string; status: string; message?: string }>;
|
||||
}
|
||||
|
||||
interface ApprovalSecuritySnapshotResponse {
|
||||
topFindings?: Array<{ cve: string; component: string; severity: string; reachability: string }>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApprovalDetailStore {
|
||||
private http = inject(HttpClient);
|
||||
private apiBase = '/api/approvals';
|
||||
private apiBase = '/api/v1/approvals';
|
||||
|
||||
// === Core State ===
|
||||
readonly approval = signal<Approval | null>(null);
|
||||
readonly diffSummary = signal<DiffSummary | null>(null);
|
||||
readonly gateResults = signal<GateResult[]>([]);
|
||||
@@ -108,14 +127,11 @@ export class ApprovalDetailStore {
|
||||
readonly comments = signal<ApprovalComment[]>([]);
|
||||
readonly securityDiff = signal<SecurityDiffEntry[]>([]);
|
||||
|
||||
// === Loading & Error State ===
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly submitting = signal(false);
|
||||
readonly commentSubmitting = signal(false);
|
||||
|
||||
// === Computed Properties ===
|
||||
|
||||
readonly approvalId = computed(() => this.approval()?.id ?? null);
|
||||
|
||||
readonly isPending = computed(() => {
|
||||
@@ -129,21 +145,10 @@ export class ApprovalDetailStore {
|
||||
return new Date(approval.expiresAt) < new Date();
|
||||
});
|
||||
|
||||
readonly canApprove = computed(() => {
|
||||
return this.isPending() && !this.hasBlockingGates() && !this.isExpired();
|
||||
});
|
||||
|
||||
readonly canReject = computed(() => {
|
||||
return this.isPending() && !this.isExpired();
|
||||
});
|
||||
|
||||
readonly hasBlockingGates = computed(() => {
|
||||
return this.gateResults().some(g => g.status === 'BLOCK');
|
||||
});
|
||||
|
||||
readonly hasWarningGates = computed(() => {
|
||||
return this.gateResults().some(g => g.status === 'WARN');
|
||||
});
|
||||
readonly canApprove = computed(() => this.isPending() && !this.hasBlockingGates() && !this.isExpired());
|
||||
readonly canReject = computed(() => this.isPending() && !this.isExpired());
|
||||
readonly hasBlockingGates = computed(() => this.gateResults().some(g => g.status === 'BLOCK'));
|
||||
readonly hasWarningGates = computed(() => this.gateResults().some(g => g.status === 'WARN'));
|
||||
|
||||
readonly overallGateStatus = computed(() => {
|
||||
const gates = this.gateResults();
|
||||
@@ -160,209 +165,160 @@ export class ApprovalDetailStore {
|
||||
});
|
||||
|
||||
readonly criticalFindings = computed(() => {
|
||||
return this.securityDiff()
|
||||
.filter(e => e.severity === 'critical' && e.changeType === 'new');
|
||||
return this.securityDiff().filter(e => e.severity === 'critical' && e.changeType === 'new');
|
||||
});
|
||||
|
||||
readonly promotionRoute = computed(() => {
|
||||
const approval = this.approval();
|
||||
if (!approval) return '';
|
||||
return `${approval.fromEnvironment} → ${approval.toEnvironment}`;
|
||||
return `${approval.fromEnvironment} -> ${approval.toEnvironment}`;
|
||||
});
|
||||
|
||||
// === Actions ===
|
||||
|
||||
/**
|
||||
* Load approval detail data
|
||||
*/
|
||||
load(approvalId: string): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.witness.set(null);
|
||||
|
||||
// In a real app, this would be HTTP calls
|
||||
// For now, simulate with mock data
|
||||
setTimeout(() => {
|
||||
this.approval.set({
|
||||
id: approvalId,
|
||||
releaseId: 'rel-123',
|
||||
releaseVersion: 'v1.2.5',
|
||||
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
|
||||
fromEnvironment: 'QA',
|
||||
toEnvironment: 'Staging',
|
||||
status: 'pending',
|
||||
requestedBy: 'ci-bot',
|
||||
requestedAt: '2026-01-17T15:00:00Z',
|
||||
expiresAt: '2026-01-24T15:00:00Z',
|
||||
});
|
||||
forkJoin({
|
||||
detail: this.http.get<ApprovalV2Dto>(`${this.apiBase}/${approvalId}`),
|
||||
gates: this.http.get<ApprovalGatesResponse>(`${this.apiBase}/${approvalId}/gates`).pipe(catchError(() => of(null))),
|
||||
security: this.http.get<ApprovalSecuritySnapshotResponse>(`${this.apiBase}/${approvalId}/security-snapshot`).pipe(catchError(() => of(null))),
|
||||
evidence: this.http.get<{ decisionDigest?: string }>(`${this.apiBase}/${approvalId}/evidence`).pipe(catchError(() => of(null))),
|
||||
ops: this.http.get<{ opsConfidence?: { status?: string } }>(`${this.apiBase}/${approvalId}/ops-health`).pipe(catchError(() => of(null))),
|
||||
}).subscribe({
|
||||
next: ({ detail, gates, security, evidence, ops }) => {
|
||||
this.approval.set(this.mapApproval(detail));
|
||||
this.gateResults.set(this.mapGates(gates?.gates ?? detail.gateResults ?? []));
|
||||
this.securityDiff.set(this.mapSecurityDiff(security?.topFindings ?? []));
|
||||
this.diffSummary.set(this.mapDiffSummary(detail, security?.topFindings ?? []));
|
||||
this.comments.set(this.mapComments(detail.actions ?? []));
|
||||
this.loading.set(false);
|
||||
|
||||
this.diffSummary.set({
|
||||
componentsAdded: 2,
|
||||
componentsRemoved: 1,
|
||||
componentsUpdated: 5,
|
||||
newCves: 3,
|
||||
fixedCves: 7,
|
||||
reachableCves: 1,
|
||||
unreachableCves: 2,
|
||||
uncertainCves: 0,
|
||||
securityScoreDelta: -5, // Lower is better
|
||||
licensesChanged: false,
|
||||
});
|
||||
|
||||
this.gateResults.set([
|
||||
{ gateId: 'sbom', name: 'SBOM Signed', status: 'PASS' },
|
||||
{ gateId: 'provenance', name: 'Provenance', status: 'PASS' },
|
||||
{ gateId: 'reachability', name: 'Reachability', status: 'WARN', reason: '1 reachable CVE', canRequestException: true },
|
||||
{ gateId: 'vex', name: 'VEX Consensus', status: 'PASS' },
|
||||
{ gateId: 'license', name: 'License Compliance', status: 'PASS' },
|
||||
]);
|
||||
|
||||
this.securityDiff.set([
|
||||
{ cveId: 'CVE-2026-1234', component: 'log4j-core', version: '2.14.1', severity: 'critical', changeType: 'new', reachability: 'reachable', confidence: 0.87 },
|
||||
{ cveId: 'CVE-2026-5678', component: 'spring-core', version: '5.3.12', severity: 'high', changeType: 'new', reachability: 'unreachable', confidence: 0.95 },
|
||||
{ cveId: 'CVE-2025-9999', component: 'jackson-databind', version: '2.13.0', severity: 'medium', changeType: 'new', reachability: 'unreachable', confidence: 0.92 },
|
||||
{ cveId: 'CVE-2025-1111', component: 'lodash', version: '4.17.19', severity: 'high', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
|
||||
{ cveId: 'CVE-2025-2222', component: 'express', version: '4.17.0', severity: 'medium', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
|
||||
]);
|
||||
|
||||
this.comments.set([
|
||||
{ id: 'c1', author: 'ci-bot', authorEmail: 'ci@acme.com', content: 'Automated promotion request triggered by successful QA deployment.', createdAt: '2026-01-17T15:00:00Z', type: 'system' },
|
||||
{ id: 'c2', author: 'Jane Smith', authorEmail: 'jane@acme.com', content: 'I\'ve reviewed the reachable CVE. The affected code path is behind a feature flag that\'s disabled in production.', createdAt: '2026-01-17T16:30:00Z', type: 'comment' },
|
||||
]);
|
||||
|
||||
this.loading.set(false);
|
||||
}, 300);
|
||||
const missingData = [gates, security, evidence, ops].filter(item => item == null).length;
|
||||
if (missingData > 0) {
|
||||
this.error.set('Approval loaded with partial v2 packet data. Some linked tabs may be unavailable.');
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.reset();
|
||||
this.error.set(this.extractErrorMessage(err, 'Failed to load approval detail'));
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the promotion request
|
||||
*/
|
||||
approve(comment?: string): void {
|
||||
const approval = this.approval();
|
||||
if (!approval || !this.canApprove()) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
console.log(`Approving ${approval.id}`, comment ? `with comment: ${comment}` : '');
|
||||
|
||||
// In real app, would POST to /api/approvals/{id}/approve
|
||||
setTimeout(() => {
|
||||
this.approval.update(a => a ? { ...a, status: 'approved', decidedAt: new Date().toISOString(), decidedBy: 'Current User' } : null);
|
||||
|
||||
if (comment) {
|
||||
this.comments.update(list => [
|
||||
...list,
|
||||
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: comment, createdAt: new Date().toISOString(), type: 'decision' },
|
||||
]);
|
||||
}
|
||||
|
||||
this.submitting.set(false);
|
||||
}, 500);
|
||||
this.error.set(null);
|
||||
this.postDecision(approval.id, 'approve', comment).subscribe({
|
||||
next: (detail) => {
|
||||
this.applyDecisionResult(detail);
|
||||
this.submitting.set(false);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.extractErrorMessage(err, 'Approve action failed'));
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the promotion request
|
||||
*/
|
||||
reject(comment: string): void {
|
||||
const approval = this.approval();
|
||||
if (!approval || !this.canReject()) return;
|
||||
|
||||
this.submitting.set(true);
|
||||
console.log(`Rejecting ${approval.id} with reason: ${comment}`);
|
||||
|
||||
// In real app, would POST to /api/approvals/{id}/reject
|
||||
setTimeout(() => {
|
||||
this.approval.update(a => a ? { ...a, status: 'rejected', decidedAt: new Date().toISOString(), decidedBy: 'Current User', decisionComment: comment } : null);
|
||||
|
||||
this.comments.update(list => [
|
||||
...list,
|
||||
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: `Rejected: ${comment}`, createdAt: new Date().toISOString(), type: 'decision' },
|
||||
]);
|
||||
|
||||
this.submitting.set(false);
|
||||
}, 500);
|
||||
this.error.set(null);
|
||||
this.postDecision(approval.id, 'reject', comment).subscribe({
|
||||
next: (detail) => {
|
||||
this.applyDecisionResult(detail);
|
||||
this.submitting.set(false);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.extractErrorMessage(err, 'Reject action failed'));
|
||||
this.submitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to the approval
|
||||
*/
|
||||
addComment(content: string): void {
|
||||
if (!content.trim()) return;
|
||||
const approval = this.approval();
|
||||
if (!approval || !content.trim()) return;
|
||||
|
||||
this.commentSubmitting.set(true);
|
||||
|
||||
// Optimistic update
|
||||
const optimisticComment: ApprovalComment = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
author: 'Current User',
|
||||
authorEmail: 'user@acme.com',
|
||||
content,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: 'comment',
|
||||
};
|
||||
|
||||
this.comments.update(list => [...list, optimisticComment]);
|
||||
|
||||
// In real app, would POST to /api/approvals/{id}/comments
|
||||
setTimeout(() => {
|
||||
// Update with real ID from server
|
||||
this.comments.update(list =>
|
||||
list.map(c => c.id === optimisticComment.id ? { ...c, id: `c${Date.now()}` } : c)
|
||||
);
|
||||
this.commentSubmitting.set(false);
|
||||
}, 200);
|
||||
this.error.set(null);
|
||||
this.postDecision(approval.id, 'comment', content.trim()).subscribe({
|
||||
next: (detail) => {
|
||||
this.applyDecisionResult(detail);
|
||||
this.commentSubmitting.set(false);
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.error.set(this.extractErrorMessage(err, 'Failed to add comment'));
|
||||
this.commentSubmitting.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an exception for a blocking gate
|
||||
*/
|
||||
requestException(gateId: string): void {
|
||||
console.log(`Requesting exception for gate: ${gateId}`);
|
||||
// This opens the exception modal - handled by the component
|
||||
// The actual exception request is handled by the modal component
|
||||
this.error.set(`Exception request for gate ${gateId} requires policy exception endpoint integration.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load witness data for a specific finding
|
||||
*/
|
||||
loadWitness(findingId: string): void {
|
||||
console.log(`Loading witness for ${findingId}`);
|
||||
const approval = this.approval();
|
||||
if (!approval) {
|
||||
this.error.set('Load approval detail before requesting witness evidence.');
|
||||
return;
|
||||
}
|
||||
|
||||
// In real app, would GET /api/witnesses/{findingId}
|
||||
setTimeout(() => {
|
||||
this.witness.set({
|
||||
findingId,
|
||||
component: 'log4j-core',
|
||||
version: '2.14.1',
|
||||
description: 'Remote code execution via JNDI lookup',
|
||||
state: 'reachable',
|
||||
confidence: 0.87,
|
||||
confidenceExplanation: 'Static analysis found path; runtime signals confirm usage',
|
||||
callPath: [
|
||||
{ function: 'main()', file: 'App.java', line: 25, type: 'entry' },
|
||||
{ function: 'handleRequest()', file: 'Controller.java', line: 142, type: 'call' },
|
||||
{ function: 'log()', file: 'LogService.java', line: 87, type: 'call' },
|
||||
{ function: 'lookup()', file: 'log4j-core/LogManager.java', line: 256, type: 'sink' },
|
||||
],
|
||||
analysisDetails: {
|
||||
guards: [],
|
||||
dynamicLoading: false,
|
||||
reflection: false,
|
||||
conditionalExecution: null,
|
||||
dataFlowConfidence: 0.92,
|
||||
},
|
||||
});
|
||||
}, 200);
|
||||
this.error.set(null);
|
||||
this.http.get<ApprovalSecuritySnapshotResponse>(`${this.apiBase}/${approval.id}/security-snapshot`).subscribe({
|
||||
next: (snapshot) => {
|
||||
const findings = snapshot.topFindings ?? [];
|
||||
const finding =
|
||||
findings.find(item => item.cve.toLowerCase() === findingId.toLowerCase()) ??
|
||||
findings.find(item => item.reachability.toLowerCase().includes('reachable')) ??
|
||||
findings[0];
|
||||
|
||||
if (!finding) {
|
||||
this.witness.set(null);
|
||||
this.error.set('No witness data available for this approval.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.witness.set({
|
||||
findingId: finding.cve,
|
||||
component: finding.component,
|
||||
version: 'unknown',
|
||||
description: `${finding.severity} finding derived from approval security snapshot`,
|
||||
state: this.mapReachabilityState(finding.reachability),
|
||||
confidence: 0.7,
|
||||
confidenceExplanation: 'Derived from approval security snapshot endpoint.',
|
||||
callPath: [
|
||||
{ function: 'approvalDecision', file: 'approval-security-snapshot', line: 1, type: 'entry' },
|
||||
{ function: 'findingResolution', file: finding.component, line: 1, type: 'sink' },
|
||||
],
|
||||
analysisDetails: {
|
||||
guards: [],
|
||||
dynamicLoading: false,
|
||||
reflection: false,
|
||||
conditionalExecution: null,
|
||||
dataFlowConfidence: 0.7,
|
||||
},
|
||||
});
|
||||
},
|
||||
error: (err: unknown) => {
|
||||
this.witness.set(null);
|
||||
this.error.set(this.extractErrorMessage(err, 'Failed to load witness data'));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear witness data
|
||||
*/
|
||||
clearWitness(): void {
|
||||
this.witness.set(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh approval data
|
||||
*/
|
||||
refresh(): void {
|
||||
const approval = this.approval();
|
||||
if (approval) {
|
||||
@@ -370,9 +326,6 @@ export class ApprovalDetailStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store state
|
||||
*/
|
||||
reset(): void {
|
||||
this.approval.set(null);
|
||||
this.diffSummary.set(null);
|
||||
@@ -385,4 +338,169 @@ export class ApprovalDetailStore {
|
||||
this.submitting.set(false);
|
||||
this.commentSubmitting.set(false);
|
||||
}
|
||||
|
||||
private postDecision(
|
||||
approvalId: string,
|
||||
action: 'approve' | 'reject' | 'comment',
|
||||
comment?: string
|
||||
) {
|
||||
return this.http.post<ApprovalV2Dto>(`${this.apiBase}/${approvalId}/decision`, {
|
||||
action,
|
||||
comment,
|
||||
actor: 'ui-operator',
|
||||
});
|
||||
}
|
||||
|
||||
private applyDecisionResult(detail: ApprovalV2Dto): void {
|
||||
const current = this.approval();
|
||||
const mapped = this.mapApproval(detail);
|
||||
this.approval.set({
|
||||
...mapped,
|
||||
decidedAt: mapped.decidedAt ?? current?.decidedAt,
|
||||
decidedBy: mapped.decidedBy ?? current?.decidedBy,
|
||||
});
|
||||
this.gateResults.set(this.mapGates(detail.gateResults ?? []));
|
||||
this.comments.set(this.mapComments(detail.actions ?? []));
|
||||
}
|
||||
|
||||
private mapApproval(detail: ApprovalV2Dto): Approval {
|
||||
const lastDecision = (detail.actions ?? [])
|
||||
.filter(item => item.action === 'approve' || item.action === 'reject')
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
releaseId: detail.releaseId,
|
||||
releaseVersion: detail.releaseVersion,
|
||||
bundleDigest: detail.manifestDigest ?? detail.releaseComponents?.[0]?.digest ?? 'unknown',
|
||||
fromEnvironment: detail.sourceEnvironment,
|
||||
toEnvironment: detail.targetEnvironment,
|
||||
status: this.mapApprovalStatus(detail.status),
|
||||
requestedBy: detail.requestedBy,
|
||||
requestedAt: detail.requestedAt,
|
||||
decidedBy: lastDecision?.actor,
|
||||
decidedAt: lastDecision?.timestamp,
|
||||
decisionComment: lastDecision?.comment,
|
||||
expiresAt: detail.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapApprovalStatus(status: string | undefined): Approval['status'] {
|
||||
switch ((status ?? '').toLowerCase()) {
|
||||
case 'approved':
|
||||
return 'approved';
|
||||
case 'rejected':
|
||||
return 'rejected';
|
||||
case 'expired':
|
||||
return 'expired';
|
||||
default:
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private mapGates(
|
||||
gates: Array<{ gateId: string; gateName: string; status: string; message?: string }>
|
||||
): GateResult[] {
|
||||
return gates.map(gate => ({
|
||||
gateId: gate.gateId,
|
||||
name: gate.gateName,
|
||||
status: this.mapGateStatus(gate.status),
|
||||
reason: gate.message,
|
||||
canRequestException: this.mapGateStatus(gate.status) !== 'PASS',
|
||||
}));
|
||||
}
|
||||
|
||||
private mapGateStatus(status: string | undefined): GateResult['status'] {
|
||||
switch ((status ?? '').toLowerCase()) {
|
||||
case 'failed':
|
||||
return 'BLOCK';
|
||||
case 'warning':
|
||||
return 'WARN';
|
||||
case 'skipped':
|
||||
return 'SKIP';
|
||||
default:
|
||||
return 'PASS';
|
||||
}
|
||||
}
|
||||
|
||||
private mapSecurityDiff(
|
||||
findings: Array<{ cve: string; component: string; severity: string; reachability: string }>
|
||||
): SecurityDiffEntry[] {
|
||||
return findings.map((finding) => ({
|
||||
cveId: finding.cve,
|
||||
component: finding.component,
|
||||
version: 'unknown',
|
||||
severity: this.mapSeverity(finding.severity),
|
||||
changeType: 'new',
|
||||
reachability: this.mapReachabilityState(finding.reachability),
|
||||
confidence: 0.7,
|
||||
}));
|
||||
}
|
||||
|
||||
private mapDiffSummary(
|
||||
detail: ApprovalV2Dto,
|
||||
findings: Array<{ cve: string; component: string; severity: string; reachability: string }>
|
||||
): DiffSummary {
|
||||
const reachable = findings.filter(item => this.mapReachabilityState(item.reachability) === 'reachable').length;
|
||||
const unreachable = findings.filter(item => this.mapReachabilityState(item.reachability) === 'unreachable').length;
|
||||
const uncertain = findings.length - reachable - unreachable;
|
||||
|
||||
return {
|
||||
componentsAdded: detail.releaseComponents?.length ?? 0,
|
||||
componentsRemoved: 0,
|
||||
componentsUpdated: detail.releaseComponents?.length ?? 0,
|
||||
newCves: findings.length,
|
||||
fixedCves: 0,
|
||||
reachableCves: reachable,
|
||||
unreachableCves: unreachable,
|
||||
uncertainCves: uncertain,
|
||||
securityScoreDelta: 0,
|
||||
licensesChanged: false,
|
||||
};
|
||||
}
|
||||
|
||||
private mapComments(actions: Array<{ id: string; action: string; actor: string; comment: string; timestamp: string }>): ApprovalComment[] {
|
||||
return actions
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||
.map(action => ({
|
||||
id: action.id,
|
||||
author: action.actor,
|
||||
authorEmail: `${action.actor}@stellaops.local`,
|
||||
content: action.comment,
|
||||
createdAt: action.timestamp,
|
||||
type: action.action === 'approve' || action.action === 'reject' ? 'decision' : 'comment',
|
||||
}));
|
||||
}
|
||||
|
||||
private mapSeverity(severity: string | undefined): SecurityDiffEntry['severity'] {
|
||||
const normalized = (severity ?? '').toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'critical':
|
||||
case 'high':
|
||||
case 'medium':
|
||||
case 'low':
|
||||
return normalized as SecurityDiffEntry['severity'];
|
||||
default:
|
||||
return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
private mapReachabilityState(reachability: string | undefined): SecurityDiffEntry['reachability'] {
|
||||
const normalized = (reachability ?? '').toLowerCase();
|
||||
if (normalized.includes('not_reachable') || normalized.includes('unreachable')) {
|
||||
return 'unreachable';
|
||||
}
|
||||
if (normalized.includes('reachable')) {
|
||||
return 'reachable';
|
||||
}
|
||||
return 'uncertain';
|
||||
}
|
||||
|
||||
private extractErrorMessage(err: unknown, fallback: string): string {
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof (err as { message?: unknown }).message === 'string') {
|
||||
return (err as { message: string }).message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RELEASE_DASHBOARD_API, type ReleaseDashboardApi } from '../../core/api/release-dashboard.client';
|
||||
import type { ActiveDeployment, DashboardData, PendingApproval, PipelineEnvironment, RecentRelease } from '../../core/api/release-dashboard.models';
|
||||
|
||||
// =============================================
|
||||
// Models
|
||||
@@ -78,6 +81,7 @@ export interface GateSummary {
|
||||
})
|
||||
export class ControlPlaneStore {
|
||||
private http = inject(HttpClient);
|
||||
private dashboardApi = inject<ReleaseDashboardApi>(RELEASE_DASHBOARD_API);
|
||||
|
||||
// =============================================
|
||||
// State Signals
|
||||
@@ -159,11 +163,17 @@ export class ControlPlaneStore {
|
||||
this.error.set(null);
|
||||
|
||||
try {
|
||||
// In production, these would be API calls
|
||||
// For now, load mock data
|
||||
await this.loadMockData();
|
||||
const data = await firstValueFrom(this.dashboardApi.getDashboardData());
|
||||
this.pipeline.set(this.mapPipeline(data));
|
||||
this.inbox.set(this.mapInbox(data.pendingApprovals, data.activeDeployments));
|
||||
this.promotions.set(this.mapPromotions(data.pendingApprovals, data.recentReleases));
|
||||
this.driftDelta.set(this.mapDriftDelta(data.pendingApprovals));
|
||||
this.lastRefresh.set(new Date());
|
||||
} catch (e) {
|
||||
this.pipeline.set(null);
|
||||
this.inbox.set(null);
|
||||
this.promotions.set([]);
|
||||
this.driftDelta.set(null);
|
||||
this.error.set(e instanceof Error ? e.message : 'Failed to load Control Plane data');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
@@ -194,9 +204,14 @@ export class ControlPlaneStore {
|
||||
* Triggers a promotion deployment.
|
||||
*/
|
||||
async deployPromotion(promotionId: string): Promise<void> {
|
||||
console.log('Deploying promotion:', promotionId);
|
||||
// TODO: API call to trigger deployment
|
||||
// await this.http.post(`/api/promotions/${promotionId}/deploy`, {}).toPromise();
|
||||
const promotion = this.promotions().find(item => item.id === promotionId);
|
||||
const releaseId = promotion?.releaseId;
|
||||
if (!releaseId) {
|
||||
this.error.set(`Promotion ${promotionId} cannot be deployed because releaseId is missing.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await firstValueFrom(this.http.post<void>(`/api/release-orchestrator/releases/${releaseId}/deploy`, {}));
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
@@ -212,138 +227,153 @@ export class ControlPlaneStore {
|
||||
// Private Methods
|
||||
// =============================================
|
||||
|
||||
private async loadMockData(): Promise<void> {
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
private mapPipeline(data: DashboardData): EnvironmentPipelineState {
|
||||
const sorted = [...data.pipelineData.environments].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Mock pipeline state
|
||||
this.pipeline.set({
|
||||
environments: [
|
||||
{
|
||||
name: 'DEV',
|
||||
version: 'v1.3.0',
|
||||
status: 'ok',
|
||||
targetCount: 4,
|
||||
healthyTargets: 4,
|
||||
lastDeployment: '10m ago',
|
||||
driftStatus: 'synced',
|
||||
},
|
||||
{
|
||||
name: 'QA',
|
||||
version: 'v1.2.5',
|
||||
status: 'ok',
|
||||
targetCount: 4,
|
||||
healthyTargets: 4,
|
||||
lastDeployment: '2h ago',
|
||||
driftStatus: 'synced',
|
||||
},
|
||||
{
|
||||
name: 'STAGING',
|
||||
version: 'v1.2.4',
|
||||
status: 'pending',
|
||||
targetCount: 6,
|
||||
healthyTargets: 6,
|
||||
lastDeployment: '6h ago',
|
||||
driftStatus: 'drifted',
|
||||
},
|
||||
{
|
||||
name: 'PROD',
|
||||
version: 'v1.2.3',
|
||||
status: 'ok',
|
||||
targetCount: 20,
|
||||
healthyTargets: 20,
|
||||
lastDeployment: '1d ago',
|
||||
driftStatus: 'synced',
|
||||
},
|
||||
],
|
||||
return {
|
||||
environments: sorted.map((env) => this.mapEnvironment(env, data.recentReleases, data.activeDeployments)),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Mock inbox
|
||||
this.inbox.set({
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'approval',
|
||||
title: '3 approvals pending',
|
||||
description: 'Release promotions awaiting review',
|
||||
severity: 'warning',
|
||||
createdAt: new Date().toISOString(),
|
||||
actionLink: '/approvals',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'blocked',
|
||||
title: '1 blocked promotion (reachability)',
|
||||
description: 'Critical CVE reachable in v1.2.6',
|
||||
severity: 'critical',
|
||||
createdAt: new Date().toISOString(),
|
||||
actionLink: '/approvals/blocked-1',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'deployment',
|
||||
title: '2 failed deployments (retry available)',
|
||||
description: 'Transient network errors',
|
||||
severity: 'warning',
|
||||
createdAt: new Date().toISOString(),
|
||||
actionLink: '/deployments?status=failed',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'key-expiry',
|
||||
title: '1 key expiring in 14 days',
|
||||
description: 'Signing key needs rotation',
|
||||
severity: 'info',
|
||||
createdAt: new Date().toISOString(),
|
||||
actionLink: '/evidence-audit/trust-signing/keys',
|
||||
},
|
||||
],
|
||||
totalCount: 4,
|
||||
});
|
||||
private mapEnvironment(
|
||||
env: PipelineEnvironment,
|
||||
releases: RecentRelease[],
|
||||
deployments: ActiveDeployment[]
|
||||
): EnvironmentState {
|
||||
const relatedDeploymentTargets = deployments
|
||||
.filter((deployment) => this.matchesEnvironment(deployment.environment, env))
|
||||
.reduce((sum, deployment) => sum + deployment.totalTargets, 0);
|
||||
|
||||
// Mock promotions
|
||||
this.promotions.set([
|
||||
{
|
||||
id: 'promo-1',
|
||||
releaseVersion: 'v1.2.5',
|
||||
releaseId: 'rel-v1.2.5',
|
||||
fromEnv: 'QA',
|
||||
toEnv: 'Staging',
|
||||
status: 'waiting',
|
||||
const targetCount = relatedDeploymentTargets > 0 ? relatedDeploymentTargets : Math.max(env.releaseCount, 1);
|
||||
const healthyTargets = env.healthStatus === 'unhealthy' ? 0 : targetCount;
|
||||
const latestRelease = releases.find((release) => this.matchesEnvironment(release.currentEnvironment, env));
|
||||
|
||||
return {
|
||||
name: env.displayName || env.name,
|
||||
version: latestRelease?.version ?? 'unknown',
|
||||
status: this.mapEnvironmentStatus(env),
|
||||
targetCount,
|
||||
healthyTargets,
|
||||
lastDeployment: latestRelease?.createdAt ? this.formatRelativeTime(latestRelease.createdAt) : 'unknown',
|
||||
driftStatus: this.mapDriftStatus(env),
|
||||
};
|
||||
}
|
||||
|
||||
private mapInbox(pending: PendingApproval[], deployments: ActiveDeployment[]): ActionInboxState {
|
||||
const pendingItems: ActionInboxItem[] = pending.map((approval) => ({
|
||||
id: approval.id,
|
||||
type: 'approval',
|
||||
title: `${approval.releaseName} ${approval.releaseVersion} pending approval`,
|
||||
description: `${approval.sourceEnvironment} -> ${approval.targetEnvironment}`,
|
||||
severity: approval.urgency === 'critical' || approval.urgency === 'high' ? 'critical' : 'warning',
|
||||
createdAt: approval.requestedAt,
|
||||
actionLink: `/release-control/approvals/${approval.id}`,
|
||||
}));
|
||||
|
||||
const deploymentItems: ActionInboxItem[] = deployments.map((deployment) => ({
|
||||
id: deployment.id,
|
||||
type: deployment.status === 'paused' ? 'blocked' : 'deployment',
|
||||
title: `${deployment.releaseName} ${deployment.releaseVersion} deployment`,
|
||||
description: `${deployment.environment} (${deployment.completedTargets}/${deployment.totalTargets} targets)`,
|
||||
severity: deployment.status === 'paused' ? 'critical' : 'info',
|
||||
createdAt: deployment.startedAt,
|
||||
actionLink: `/release-control/runs`,
|
||||
}));
|
||||
|
||||
const items = [...pendingItems, ...deploymentItems]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return {
|
||||
items,
|
||||
totalCount: items.length,
|
||||
};
|
||||
}
|
||||
|
||||
private mapPromotions(pending: PendingApproval[], releases: RecentRelease[]): PendingPromotion[] {
|
||||
return pending.map((approval) => {
|
||||
const release = releases.find((item) => item.id === approval.releaseId);
|
||||
const gateStatus: GateSummary['status'] = approval.urgency === 'critical' ? 'WARN' : 'PASS';
|
||||
|
||||
return {
|
||||
id: approval.id,
|
||||
releaseVersion: approval.releaseVersion,
|
||||
releaseId: approval.releaseId,
|
||||
fromEnv: approval.sourceEnvironment,
|
||||
toEnv: approval.targetEnvironment,
|
||||
status: release?.status === 'ready' ? 'auto-approved' : 'waiting',
|
||||
gates: [
|
||||
{ name: 'SBOM', status: 'PASS' },
|
||||
{ name: 'Reachability', status: 'WARN' },
|
||||
{ name: 'Promotion Readiness', status: gateStatus },
|
||||
],
|
||||
riskDelta: '+2 new CVEs',
|
||||
requestedAt: new Date().toISOString(),
|
||||
requestedBy: 'ci-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'promo-2',
|
||||
releaseVersion: 'v1.2.6',
|
||||
releaseId: 'rel-v1.2.6',
|
||||
fromEnv: 'Dev',
|
||||
toEnv: 'QA',
|
||||
status: 'auto-approved',
|
||||
gates: [
|
||||
{ name: 'SBOM', status: 'PASS' },
|
||||
{ name: 'Reachability', status: 'PASS' },
|
||||
],
|
||||
riskDelta: 'net safer',
|
||||
requestedAt: new Date().toISOString(),
|
||||
requestedBy: 'ci-pipeline',
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock drift delta
|
||||
this.driftDelta.set({
|
||||
promotionsBlocked: 2,
|
||||
cvesUpdated: 5,
|
||||
reachableCves: 1,
|
||||
feedStaleRisks: 1,
|
||||
configDrifts: 0,
|
||||
lastEvidenceTime: new Date(Date.now() - 3600000).toISOString(),
|
||||
riskDelta: approval.urgency === 'critical' ? 'heightened review required' : 'stable',
|
||||
requestedAt: approval.requestedAt,
|
||||
requestedBy: approval.requestedBy,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private mapDriftDelta(pending: PendingApproval[]): DriftRiskDelta {
|
||||
const blocked = pending.filter((approval) => approval.urgency === 'critical').length;
|
||||
return {
|
||||
promotionsBlocked: blocked,
|
||||
cvesUpdated: 0,
|
||||
reachableCves: blocked,
|
||||
feedStaleRisks: 0,
|
||||
configDrifts: 0,
|
||||
lastEvidenceTime: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapEnvironmentStatus(env: PipelineEnvironment): EnvironmentState['status'] {
|
||||
if (env.healthStatus === 'unhealthy') {
|
||||
return 'failed';
|
||||
}
|
||||
if (env.healthStatus === 'degraded') {
|
||||
return 'blocked';
|
||||
}
|
||||
if (env.pendingCount > 0) {
|
||||
return 'pending';
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
private mapDriftStatus(env: PipelineEnvironment): EnvironmentState['driftStatus'] {
|
||||
if (env.healthStatus === 'unknown') {
|
||||
return 'unknown';
|
||||
}
|
||||
if (env.healthStatus === 'degraded' || env.healthStatus === 'unhealthy') {
|
||||
return 'drifted';
|
||||
}
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
private matchesEnvironment(environmentName: string | null | undefined, env: PipelineEnvironment): boolean {
|
||||
if (!environmentName) {
|
||||
return false;
|
||||
}
|
||||
const normalized = environmentName.toLowerCase();
|
||||
return normalized === env.name.toLowerCase() || normalized === env.displayName.toLowerCase();
|
||||
}
|
||||
|
||||
private formatRelativeTime(isoTime: string): string {
|
||||
const millis = Date.now() - new Date(isoTime).getTime();
|
||||
if (!Number.isFinite(millis) || millis < 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(millis / 60000);
|
||||
if (minutes < 1) {
|
||||
return 'just now';
|
||||
}
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m ago`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return `${hours}h ago`;
|
||||
}
|
||||
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/**
|
||||
/**
|
||||
* Dashboard V3 - Mission Board
|
||||
* Sprint: SPRINT_20260218_012_FE_ui_v2_rewire_dashboard_v3_mission_board (D7-01 through D7-05)
|
||||
*
|
||||
@@ -94,19 +94,19 @@ interface MissionSummary {
|
||||
<div class="summary-card" [class.warning]="summary().blockedPromotions > 0">
|
||||
<div class="summary-value">{{ summary().activePromotions }}</div>
|
||||
<div class="summary-label">Active Promotions</div>
|
||||
<a routerLink="/release-control/promotions" class="summary-link">View all</a>
|
||||
<a routerLink="/releases/runs" class="summary-link">View all</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.critical]="summary().blockedPromotions > 0">
|
||||
<div class="summary-value">{{ summary().blockedPromotions }}</div>
|
||||
<div class="summary-label">Blocked Promotions</div>
|
||||
<a routerLink="/release-control/approvals" class="summary-link">Review</a>
|
||||
<a routerLink="/releases/approvals" class="summary-link">Review</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card">
|
||||
<div class="summary-value env-name">{{ summary().highestRiskEnv }}</div>
|
||||
<div class="summary-label">Highest Risk Environment</div>
|
||||
<a routerLink="/security-risk/risk" class="summary-link">Risk detail</a>
|
||||
<a routerLink="/security" class="summary-link">Risk detail</a>
|
||||
</div>
|
||||
|
||||
<div class="summary-card" [class.warning]="summary().dataIntegrityStatus === 'degraded'"
|
||||
@@ -116,7 +116,7 @@ interface MissionSummary {
|
||||
{{ summary().dataIntegrityStatus | titlecase }}
|
||||
</div>
|
||||
<div class="summary-label">Data Integrity</div>
|
||||
<a routerLink="/platform-ops/data-integrity" class="summary-link">Ops detail</a>
|
||||
<a routerLink="/platform/ops/data-integrity" class="summary-link">Ops detail</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +124,7 @@ interface MissionSummary {
|
||||
<section class="pipeline-board" aria-label="Regional pipeline board">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Regional Pipeline</h2>
|
||||
<a routerLink="/release-control/environments" class="section-link">All environments</a>
|
||||
<a routerLink="/topology/environments" class="section-link">All environments</a>
|
||||
</div>
|
||||
|
||||
<div class="env-grid">
|
||||
@@ -174,10 +174,10 @@ interface MissionSummary {
|
||||
<div class="env-card-footer">
|
||||
<span class="last-deployed">Deployed {{ env.lastDeployedAt }}</span>
|
||||
<div class="env-links">
|
||||
<a [routerLink]="['/release-control/environments', env.id]" class="env-link">
|
||||
<a [routerLink]="['/topology/environments', env.id, 'posture']" class="env-link">
|
||||
Detail
|
||||
</a>
|
||||
<a [routerLink]="['/security-risk/findings']" [queryParams]="{ env: env.id }" class="env-link">
|
||||
<a [routerLink]="['/security/findings']" [queryParams]="{ env: env.id }" class="env-link">
|
||||
Findings
|
||||
</a>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@ interface MissionSummary {
|
||||
<section class="risk-table" aria-label="Environments at risk">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Environments at Risk</h2>
|
||||
<a routerLink="/release-control/environments" class="section-link">Open environments</a>
|
||||
<a routerLink="/topology/environments" class="section-link">Open environments</a>
|
||||
</div>
|
||||
|
||||
@if (riskEnvironments().length === 0) {
|
||||
@@ -227,7 +227,7 @@ interface MissionSummary {
|
||||
<td>{{ env.birCoverage }}</td>
|
||||
<td>{{ env.lastDeployedAt }}</td>
|
||||
<td>
|
||||
<a [routerLink]="['/release-control/environments', env.id]">Open</a>
|
||||
<a [routerLink]="['/topology/environments', env.id, 'posture']">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -243,7 +243,7 @@ interface MissionSummary {
|
||||
<section class="domain-card" aria-label="SBOM snapshot">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">SBOM Findings Snapshot</h2>
|
||||
<a routerLink="/security-risk/sbom" class="card-link">View SBOM</a>
|
||||
<a routerLink="/security/sbom/lake" class="card-link">View SBOM</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="snapshot-stat">
|
||||
@@ -265,8 +265,8 @@ interface MissionSummary {
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a routerLink="/security-risk/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
|
||||
<a routerLink="/release-control" class="card-action">Release Control</a>
|
||||
<a routerLink="/security/findings" [queryParams]="{ reachability: 'critical' }" class="card-action">Open Findings</a>
|
||||
<a routerLink="/releases/runs" class="card-action">Release Runs</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -274,7 +274,7 @@ interface MissionSummary {
|
||||
<section class="domain-card" aria-label="Reachability summary">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Reachability</h2>
|
||||
<a routerLink="/security-risk/reachability" class="card-link">View reachability</a>
|
||||
<a routerLink="/security/findings" class="card-link">View reachability</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="bir-matrix">
|
||||
@@ -305,7 +305,7 @@ interface MissionSummary {
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a routerLink="/security-risk/reachability" class="card-action">Deep analysis</a>
|
||||
<a routerLink="/security/findings" class="card-action">Deep analysis</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -313,7 +313,7 @@ interface MissionSummary {
|
||||
<section class="domain-card" aria-label="Nightly ops signals">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Nightly Ops Signals</h2>
|
||||
<a routerLink="/platform-ops/data-integrity" class="card-link">Open Data Integrity</a>
|
||||
<a routerLink="/platform/ops/data-integrity" class="card-link">Open Data Integrity</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@for (signal of nightlyOpsSignals(); track signal.id) {
|
||||
@@ -327,32 +327,32 @@ interface MissionSummary {
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<a routerLink="/platform-ops/data-integrity" class="card-action">Open Data Integrity</a>
|
||||
<a routerLink="/platform/ops/data-integrity" class="card-action">Open Data Integrity</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Cross-domain navigation links -->
|
||||
<nav class="domain-nav" aria-label="Domain navigation">
|
||||
<a routerLink="/release-control" class="domain-nav-item">
|
||||
<a routerLink="/releases/runs" class="domain-nav-item">
|
||||
<span class="domain-icon">▶</span>
|
||||
Release Control
|
||||
Release Runs
|
||||
</a>
|
||||
<a routerLink="/security-risk" class="domain-nav-item">
|
||||
<a routerLink="/security" class="domain-nav-item">
|
||||
<span class="domain-icon">■</span>
|
||||
Security & Risk
|
||||
</a>
|
||||
<a routerLink="/platform-ops" class="domain-nav-item">
|
||||
<a routerLink="/platform/ops" class="domain-nav-item">
|
||||
<span class="domain-icon">◆</span>
|
||||
Platform Ops
|
||||
Platform
|
||||
</a>
|
||||
<a routerLink="/evidence-audit" class="domain-nav-item">
|
||||
<a routerLink="/evidence" class="domain-nav-item">
|
||||
<span class="domain-icon">●</span>
|
||||
Evidence & Audit
|
||||
Evidence (Decision Capsules)
|
||||
</a>
|
||||
<a routerLink="/administration" class="domain-nav-item">
|
||||
<a routerLink="/platform/setup" class="domain-nav-item">
|
||||
<span class="domain-icon">⚙</span>
|
||||
Administration
|
||||
Platform Setup
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -1033,3 +1033,4 @@ export class DashboardV3Component {
|
||||
this.selectedTimeWindow.set(select.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,27 @@
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4 class="section-title">Approvals</h4>
|
||||
@if ((exception()!.approvals ?? []).length === 0) {
|
||||
<span class="detail-value">No approvals recorded.</span>
|
||||
} @else {
|
||||
<ul class="audit-list">
|
||||
@for (approval of exception()!.approvals ?? []; track approval.approvalId) {
|
||||
<li>
|
||||
<span class="detail-label">{{ approval.approvedBy }}</span>
|
||||
<span class="detail-value">
|
||||
{{ formatDate(approval.approvedAt) }}
|
||||
@if (approval.comment) {
|
||||
· {{ approval.comment }}
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<h4 class="section-title">Extend expiry</h4>
|
||||
<div class="extend-row">
|
||||
|
||||
@@ -44,7 +44,7 @@ export type ActivityEventType =
|
||||
template: `
|
||||
<div class="integration-activity">
|
||||
<header class="activity-header">
|
||||
<a routerLink="/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
|
||||
<a routerLink="/platform/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
|
||||
<h1>Integration Activity</h1>
|
||||
<p class="subtitle">Audit trail for all integration lifecycle events</p>
|
||||
</header>
|
||||
@@ -127,7 +127,7 @@ export type ActivityEventType =
|
||||
<span class="event-timestamp">{{ formatTimestamp(event.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="event-title">
|
||||
<a [routerLink]="['/integrations', event.integrationId]" class="integration-link">
|
||||
<a [routerLink]="['/platform/integrations', event.integrationId]" class="integration-link">
|
||||
{{ event.integrationName }}
|
||||
</a>
|
||||
<span class="provider-badge">{{ event.integrationProvider }}</span>
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getProviderLabel,
|
||||
} from './integration.models';
|
||||
|
||||
type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'events' | 'health';
|
||||
|
||||
/**
|
||||
* Integration detail component showing health, activity, and configuration.
|
||||
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
@if (integration) {
|
||||
<div class="integration-detail">
|
||||
<header class="detail-header">
|
||||
<a routerLink="/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
|
||||
<a routerLink="/platform/integrations" class="back-link"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align: middle; margin-right: 4px;"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>Back to Integrations</a>
|
||||
<h1>{{ integration.name }}</h1>
|
||||
<span [class]="'status-badge status-' + getStatusColor(integration.status)">
|
||||
{{ getStatusLabel(integration.status) }}
|
||||
@@ -56,9 +58,10 @@ import {
|
||||
</section>
|
||||
<nav class="detail-tabs">
|
||||
<button [class.active]="activeTab === 'overview'" (click)="activeTab = 'overview'">Overview</button>
|
||||
<button [class.active]="activeTab === 'credentials'" (click)="activeTab = 'credentials'">Credentials</button>
|
||||
<button [class.active]="activeTab === 'scopes-rules'" (click)="activeTab = 'scopes-rules'">Scopes & Rules</button>
|
||||
<button [class.active]="activeTab === 'events'" (click)="activeTab = 'events'">Events</button>
|
||||
<button [class.active]="activeTab === 'health'" (click)="activeTab = 'health'">Health</button>
|
||||
<button [class.active]="activeTab === 'activity'" (click)="activeTab = 'activity'">Activity</button>
|
||||
<button [class.active]="activeTab === 'settings'" (click)="activeTab = 'settings'">Settings</button>
|
||||
</nav>
|
||||
<section class="tab-content">
|
||||
@switch (activeTab) {
|
||||
@@ -91,10 +94,62 @@ import {
|
||||
</div>
|
||||
}
|
||||
@if (!integration.tags) {
|
||||
<p class="placeholder">No tags.</p>
|
||||
<p class="placeholder">No tags.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('credentials') {
|
||||
<div class="tab-panel">
|
||||
<h2>Credentials</h2>
|
||||
<dl class="config-list">
|
||||
<dt>Auth Reference</dt>
|
||||
<dd>{{ integration.authRef || 'Not configured' }}</dd>
|
||||
<dt>Credential Status</dt>
|
||||
<dd>{{ integration.lastTestSuccess ? 'Valid on last check' : 'Requires attention' }}</dd>
|
||||
<dt>Last Validation</dt>
|
||||
<dd>{{ integration.lastTestedAt ? (integration.lastTestedAt | date:'medium') : 'Never' }}</dd>
|
||||
<dt>Rotation</dt>
|
||||
<dd>Managed by integration owner workflow.</dd>
|
||||
</dl>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
|
||||
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@case ('scopes-rules') {
|
||||
<div class="tab-panel">
|
||||
<h2>Scopes & Rules</h2>
|
||||
<ul class="rules-list">
|
||||
@for (rule of scopeRules; track rule) {
|
||||
<li>{{ rule }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@case ('events') {
|
||||
<div class="tab-panel">
|
||||
<h2>Events</h2>
|
||||
<table class="event-table" aria-label="Integration events table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Event</th>
|
||||
<th>Correlation ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (event of recentEvents; track event.timestamp + event.correlationId) {
|
||||
<tr>
|
||||
<td>{{ event.timestamp }}</td>
|
||||
<td>{{ event.message }}</td>
|
||||
<td>{{ event.correlationId }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@case ('health') {
|
||||
<div class="tab-panel">
|
||||
<h2>Health</h2>
|
||||
@@ -129,21 +184,6 @@ import {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('activity') {
|
||||
<div class="tab-panel">
|
||||
<h2>Activity</h2>
|
||||
<p class="placeholder">Activity timeline coming soon...</p>
|
||||
</div>
|
||||
}
|
||||
@case ('settings') {
|
||||
<div class="tab-panel">
|
||||
<h2>Settings</h2>
|
||||
<div class="settings-actions">
|
||||
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
|
||||
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
@@ -271,6 +311,37 @@ import {
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.event-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.event-table th,
|
||||
.event-table td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-table th {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary, .btn-danger {
|
||||
@@ -329,11 +400,21 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
readonly failureIconSvg = `<svg ${this.svgAttrs}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`;
|
||||
|
||||
integration?: Integration;
|
||||
activeTab = 'overview';
|
||||
activeTab: IntegrationDetailTab = 'overview';
|
||||
testing = false;
|
||||
checking = false;
|
||||
lastTestResult?: TestConnectionResponse;
|
||||
lastHealthResult?: IntegrationHealthResponse;
|
||||
readonly scopeRules = [
|
||||
'Read scope required for release and evidence queries.',
|
||||
'Write scope required only for connector mutation operations.',
|
||||
'Production connectors require explicit approval before credential updates.',
|
||||
];
|
||||
readonly recentEvents = [
|
||||
{ timestamp: '2026-02-20 10:04 UTC', message: 'Health check passed', correlationId: 'corr-int-1004' },
|
||||
{ timestamp: '2026-02-20 09:42 UTC', message: 'Token validation warning (latency)', correlationId: 'corr-int-0942' },
|
||||
{ timestamp: '2026-02-20 08:18 UTC', message: 'Connection test executed', correlationId: 'corr-int-0818' },
|
||||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
const integrationId = this.route.snapshot.paramMap.get('integrationId');
|
||||
@@ -408,7 +489,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
|
||||
editIntegration(): void {
|
||||
if (!this.integration) return;
|
||||
void this.router.navigate(['/integrations', this.integration.integrationId], {
|
||||
void this.router.navigate(['/platform/integrations', this.integration.integrationId], {
|
||||
queryParams: { edit: '1' },
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
@@ -419,7 +500,7 @@ export class IntegrationDetailComponent implements OnInit {
|
||||
if (confirm('Are you sure you want to delete this integration?')) {
|
||||
this.integrationService.delete(this.integration.integrationId).subscribe({
|
||||
next: () => {
|
||||
void this.router.navigate(['/integrations']);
|
||||
void this.router.navigate(['/platform/integrations']);
|
||||
},
|
||||
error: (err) => {
|
||||
alert('Failed to delete integration: ' + err.message);
|
||||
|
||||
@@ -1,211 +1,161 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
|
||||
import { IntegrationService } from './integration.service';
|
||||
import { IntegrationType } from './integration.models';
|
||||
|
||||
/**
|
||||
* Integration Hub main dashboard component.
|
||||
* Sprint: SPRINT_20251229_011_FE_integration_hub_ui
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-integration-hub',
|
||||
imports: [RouterModule],
|
||||
template: `
|
||||
<div class="integration-hub">
|
||||
<header class="hub-header">
|
||||
selector: 'app-integration-hub',
|
||||
standalone: true,
|
||||
imports: [RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="integration-hub">
|
||||
<header>
|
||||
<h1>Integration Hub</h1>
|
||||
<p class="subtitle">
|
||||
Manage registries, SCM providers, CI systems, and feed sources.
|
||||
<p>
|
||||
External system connectors for release, security, and evidence flows.
|
||||
Topology runtime inventory is managed under Topology.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav class="hub-nav">
|
||||
<a routerLink="registries" routerLinkActive="active" class="nav-tile">
|
||||
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg></span>
|
||||
<span class="tile-label">Registries</span>
|
||||
<span class="tile-count">{{ stats.registries }}</span>
|
||||
<nav class="tiles">
|
||||
<a routerLink="registries" class="tile">
|
||||
<span>Registries</span>
|
||||
<strong>{{ stats.registries }}</strong>
|
||||
</a>
|
||||
<a routerLink="scm" routerLinkActive="active" class="nav-tile">
|
||||
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
<span class="tile-label">SCM</span>
|
||||
<span class="tile-count">{{ stats.scm }}</span>
|
||||
<a routerLink="scm" class="tile">
|
||||
<span>SCM</span>
|
||||
<strong>{{ stats.scm }}</strong>
|
||||
</a>
|
||||
<a routerLink="ci" routerLinkActive="active" class="nav-tile">
|
||||
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
<span class="tile-label">CI/CD</span>
|
||||
<span class="tile-count">{{ stats.ci }}</span>
|
||||
<a routerLink="ci" class="tile">
|
||||
<span>CI/CD</span>
|
||||
<strong>{{ stats.ci }}</strong>
|
||||
</a>
|
||||
<a routerLink="hosts" routerLinkActive="active" class="nav-tile">
|
||||
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></span>
|
||||
<span class="tile-label">Hosts</span>
|
||||
<span class="tile-count">{{ stats.hosts }}</span>
|
||||
<a routerLink="runtime-hosts" class="tile">
|
||||
<span>Runtimes / Hosts</span>
|
||||
<strong>{{ stats.runtimeHosts }}</strong>
|
||||
</a>
|
||||
<a routerLink="feeds" routerLinkActive="active" class="nav-tile">
|
||||
<span class="tile-icon"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg></span>
|
||||
<span class="tile-label">Feeds</span>
|
||||
<span class="tile-count">{{ stats.feeds }}</span>
|
||||
<a routerLink="feeds" class="tile">
|
||||
<span>Advisory Sources</span>
|
||||
<strong>{{ stats.advisorySources }}</strong>
|
||||
</a>
|
||||
<a routerLink="vex-sources" class="tile">
|
||||
<span>VEX Sources</span>
|
||||
<strong>{{ stats.vexSources }}</strong>
|
||||
</a>
|
||||
<a routerLink="secrets" class="tile">
|
||||
<span>Secrets</span>
|
||||
<strong>{{ stats.secrets }}</strong>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<section class="hub-actions">
|
||||
<button class="btn-primary" (click)="addIntegration()">
|
||||
+ Add Integration
|
||||
</button>
|
||||
<a routerLink="activity" class="btn-secondary">View Activity</a>
|
||||
<section class="actions">
|
||||
<button type="button" (click)="addIntegration()">+ Add Integration</button>
|
||||
<a routerLink="activity">View Activity</a>
|
||||
</section>
|
||||
|
||||
<section class="hub-summary">
|
||||
<section class="activity" aria-live="polite">
|
||||
<h2>Recent Activity</h2>
|
||||
<div class="coming-soon" role="status" aria-live="polite">
|
||||
<div class="coming-soon__icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="coming-soon__title">Activity stream is coming soon</p>
|
||||
<p class="coming-soon__description">
|
||||
Connector timeline events will appear here once ingestion telemetry is fully enabled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="activity__title">Activity stream is coming soon</p>
|
||||
<p class="activity__text">
|
||||
Connector timeline events will appear here once integration telemetry wiring is complete.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
styles: [`
|
||||
.integration-hub {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hub-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hub-header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hub-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
gap: 0.8rem;
|
||||
max-width: 1150px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.nav-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--color-surface-primary);
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.tile {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.nav-tile:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.nav-tile.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
background: var(--color-nav-hover);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.tile-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.tile-count {
|
||||
font-size: 0.875rem;
|
||||
.tile span {
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.hub-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn-primary, .btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-brand-primary);
|
||||
.tile strong {
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-heading);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-brand-primary);
|
||||
border: 1px solid var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.hub-summary h2 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions button,
|
||||
.actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
text-decoration: none;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.activity {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.coming-soon__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-brand-primary);
|
||||
background: var(--color-brand-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coming-soon__title {
|
||||
.activity h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.activity__title {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.coming-soon__description {
|
||||
margin: 0.25rem 0 0;
|
||||
.activity__text {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
`]
|
||||
`],
|
||||
})
|
||||
export class IntegrationHubComponent {
|
||||
private readonly integrationService = inject(IntegrationService);
|
||||
@@ -215,8 +165,10 @@ export class IntegrationHubComponent {
|
||||
registries: 0,
|
||||
scm: 0,
|
||||
ci: 0,
|
||||
hosts: 0,
|
||||
feeds: 0,
|
||||
runtimeHosts: 0,
|
||||
advisorySources: 0,
|
||||
vexSources: 0,
|
||||
secrets: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -224,25 +176,39 @@ export class IntegrationHubComponent {
|
||||
}
|
||||
|
||||
private loadStats(): void {
|
||||
// Load integration counts by type
|
||||
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.stats.registries = res.totalCount,
|
||||
next: (res) => (this.stats.registries = res.totalCount),
|
||||
error: () => (this.stats.registries = 0),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.stats.scm = res.totalCount,
|
||||
next: (res) => (this.stats.scm = res.totalCount),
|
||||
error: () => (this.stats.scm = 0),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.stats.ci = res.totalCount,
|
||||
next: (res) => (this.stats.ci = res.totalCount),
|
||||
error: () => (this.stats.ci = 0),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.stats.hosts = res.totalCount,
|
||||
next: (res) => (this.stats.runtimeHosts = res.totalCount),
|
||||
error: () => (this.stats.runtimeHosts = 0),
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
|
||||
next: (res) => this.stats.feeds = res.totalCount,
|
||||
next: (res) => {
|
||||
this.stats.advisorySources = res.totalCount;
|
||||
this.stats.vexSources = res.totalCount;
|
||||
},
|
||||
error: () => {
|
||||
this.stats.advisorySources = 0;
|
||||
this.stats.vexSources = 0;
|
||||
},
|
||||
});
|
||||
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
|
||||
next: (res) => (this.stats.secrets = res.totalCount),
|
||||
error: () => (this.stats.secrets = 0),
|
||||
});
|
||||
}
|
||||
|
||||
addIntegration(): void {
|
||||
void this.router.navigate(['/integrations/onboarding/registry']);
|
||||
void this.router.navigate(['/platform/integrations/onboarding/registry']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
/**
|
||||
* Integration Hub Routes
|
||||
* Updated: SPRINT_20260218_008_FE_ui_v2_rewire_integrations_platform_ops_data_integrity (I3-01, I3-03)
|
||||
* Updated: SPRINT_20260220_031_FE_platform_global_ops_integrations_setup_advisory_recheck
|
||||
*
|
||||
* Canonical Integrations taxonomy:
|
||||
* '' — Hub overview with health summary and category navigation
|
||||
* registries — Container registries
|
||||
* scm — Source control managers
|
||||
* ci — CI/CD pipelines
|
||||
* hosts — Target runtimes / hosts
|
||||
* secrets — Secrets managers / vaults
|
||||
* feeds — Advisory feed connectors
|
||||
* notifications — Notification providers
|
||||
* :id — Integration detail (standard contract template)
|
||||
* '' - Hub overview with health summary and category navigation
|
||||
* registries - Container registries
|
||||
* scm - Source control managers
|
||||
* ci - CI/CD systems
|
||||
* runtime-hosts - Runtime and host connector inventory
|
||||
* feeds - Advisory source connectors
|
||||
* vex-sources - VEX source connectors
|
||||
* secrets - Secrets managers / vaults
|
||||
* :id - Integration detail (standard contract template)
|
||||
*
|
||||
* Data Integrity cross-link: connectivity/freshness owned here;
|
||||
* decision impact consumed by Security & Risk.
|
||||
* Ownership boundary:
|
||||
* hosts/targets/agents are managed in Topology and only aliased here.
|
||||
*/
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const integrationHubRoutes: Routes = [
|
||||
// Root — Integrations overview with health summary and category navigation
|
||||
{
|
||||
path: '',
|
||||
title: 'Integrations',
|
||||
@@ -29,7 +28,6 @@ export const integrationHubRoutes: Routes = [
|
||||
import('./integration-hub.component').then((m) => m.IntegrationHubComponent),
|
||||
},
|
||||
|
||||
// Onboarding flow
|
||||
{
|
||||
path: 'onboarding',
|
||||
title: 'Add Integration',
|
||||
@@ -45,7 +43,6 @@ export const integrationHubRoutes: Routes = [
|
||||
import('../integrations/integrations-hub.component').then((m) => m.IntegrationsHubComponent),
|
||||
},
|
||||
|
||||
// Category: Container Registries
|
||||
{
|
||||
path: 'registries',
|
||||
title: 'Registries',
|
||||
@@ -53,8 +50,6 @@ export const integrationHubRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
|
||||
// Category: Source Control
|
||||
{
|
||||
path: 'scm',
|
||||
title: 'Source Control',
|
||||
@@ -62,8 +57,6 @@ export const integrationHubRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
|
||||
// Category: CI/CD Pipelines
|
||||
{
|
||||
path: 'ci',
|
||||
title: 'CI/CD',
|
||||
@@ -76,45 +69,69 @@ export const integrationHubRoutes: Routes = [
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'ci',
|
||||
},
|
||||
|
||||
// Category: Targets / Runtimes
|
||||
{
|
||||
path: 'hosts',
|
||||
title: 'Targets / Runtimes',
|
||||
data: { breadcrumb: 'Targets / Runtimes', type: 'Host' },
|
||||
path: 'runtime-hosts',
|
||||
title: 'Runtimes / Hosts',
|
||||
data: { breadcrumb: 'Runtimes / Hosts', type: 'RuntimeHost' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
{
|
||||
path: 'runtimes-hosts',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'runtime-hosts',
|
||||
},
|
||||
|
||||
// Topology ownership aliases.
|
||||
{
|
||||
path: 'hosts',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/topology/hosts',
|
||||
},
|
||||
{
|
||||
path: 'targets-runtimes',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'hosts',
|
||||
redirectTo: '/topology/targets',
|
||||
},
|
||||
{
|
||||
path: 'targets',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'hosts',
|
||||
redirectTo: '/topology/targets',
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/topology/agents',
|
||||
},
|
||||
|
||||
{
|
||||
path: 'feeds',
|
||||
title: 'Advisory Sources',
|
||||
data: { breadcrumb: 'Advisory Sources', type: 'FeedMirror' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
{
|
||||
path: 'vex-sources',
|
||||
title: 'VEX Sources',
|
||||
data: { breadcrumb: 'VEX Sources', type: 'FeedMirror' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
{
|
||||
path: 'advisory-vex',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'feeds',
|
||||
},
|
||||
|
||||
// Category: Secrets Managers
|
||||
{
|
||||
path: 'secrets',
|
||||
title: 'Secrets',
|
||||
data: { breadcrumb: 'Secrets', type: 'Secrets' },
|
||||
data: { breadcrumb: 'Secrets', type: 'RepoSource' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
|
||||
// Category: Advisory Feed Connectors
|
||||
{
|
||||
path: 'feeds',
|
||||
title: 'Advisory Feeds',
|
||||
data: { breadcrumb: 'Advisory Feeds', type: 'Feed' },
|
||||
loadComponent: () =>
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
|
||||
// Category: Notification Providers
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Notification Providers',
|
||||
@@ -123,7 +140,6 @@ export const integrationHubRoutes: Routes = [
|
||||
import('./integration-list.component').then((m) => m.IntegrationListComponent),
|
||||
},
|
||||
|
||||
// SBOM sources (canonical path under integrations)
|
||||
{
|
||||
path: 'sbom-sources',
|
||||
title: 'SBOM Sources',
|
||||
@@ -132,7 +148,6 @@ export const integrationHubRoutes: Routes = [
|
||||
import('../sbom-sources/sbom-sources.routes').then((m) => m.SBOM_SOURCES_ROUTES),
|
||||
},
|
||||
|
||||
// Activity log
|
||||
{
|
||||
path: 'activity',
|
||||
title: 'Activity',
|
||||
@@ -141,7 +156,6 @@ export const integrationHubRoutes: Routes = [
|
||||
import('./integration-activity.component').then((m) => m.IntegrationActivityComponent),
|
||||
},
|
||||
|
||||
// Integration detail — standard contract template (I3-03)
|
||||
{
|
||||
path: ':integrationId',
|
||||
title: 'Integration Detail',
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
@for (integration of integrations; track integration.integrationId) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/integrations', integration.integrationId]">{{ integration.name }}</a>
|
||||
<a [routerLink]="['/platform/integrations', integration.integrationId]">{{ integration.name }}</a>
|
||||
</td>
|
||||
<td>{{ getProviderName(integration.provider) }}</td>
|
||||
<td>
|
||||
@@ -85,7 +85,7 @@ import {
|
||||
<button (click)="editIntegration(integration)" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg></button>
|
||||
<button (click)="testConnection(integration)" title="Test Connection"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22v-5"/><path d="M9 7V2"/><path d="M15 7V2"/><path d="M6 13V8h12v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4Z"/></svg></button>
|
||||
<button (click)="checkHealth(integration)" title="Check Health"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg></button>
|
||||
<a [routerLink]="['/integrations', integration.integrationId]" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
|
||||
<a [routerLink]="['/platform/integrations', integration.integrationId]" title="View Details"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -331,12 +331,12 @@ export class IntegrationListComponent implements OnInit {
|
||||
}
|
||||
|
||||
editIntegration(integration: Integration): void {
|
||||
void this.router.navigate(['/integrations', integration.integrationId], { queryParams: { edit: true } });
|
||||
void this.router.navigate(['/platform/integrations', integration.integrationId], { queryParams: { edit: true } });
|
||||
}
|
||||
|
||||
addIntegration(): void {
|
||||
void this.router.navigate(
|
||||
['/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
|
||||
['/platform/integrations/onboarding', this.getOnboardingTypeSegment(this.integrationType)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,7 +363,9 @@ export class IntegrationListComponent implements OnInit {
|
||||
case IntegrationType.RuntimeHost:
|
||||
return 'host';
|
||||
case IntegrationType.FeedMirror:
|
||||
return 'registry';
|
||||
return 'feed';
|
||||
case IntegrationType.RepoSource:
|
||||
return 'secrets';
|
||||
case IntegrationType.Registry:
|
||||
default:
|
||||
return 'registry';
|
||||
|
||||
@@ -10,12 +10,14 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { UpperCasePipe } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
type SignalStatus = 'ok' | 'warn' | 'fail';
|
||||
type SignalState = 'ok' | 'warn' | 'fail';
|
||||
type SignalImpact = 'BLOCKING' | 'DEGRADED' | 'INFO';
|
||||
|
||||
interface TrustSignal {
|
||||
id: string;
|
||||
label: string;
|
||||
status: SignalStatus;
|
||||
state: SignalState;
|
||||
impact: SignalImpact;
|
||||
detail: string;
|
||||
route: string;
|
||||
}
|
||||
@@ -74,8 +76,14 @@ interface FailureItem {
|
||||
@for (item of trustSignals; track item.id) {
|
||||
<a class="trust-item" [routerLink]="item.route">
|
||||
<span class="trust-item__label">{{ item.label }}</span>
|
||||
<span class="trust-item__status" [class]="'trust-item__status trust-item__status--' + item.status">
|
||||
{{ item.status | uppercase }}
|
||||
<span class="trust-item__status" [class]="'trust-item__status trust-item__status--' + item.state">
|
||||
{{ item.state | uppercase }}
|
||||
</span>
|
||||
<span
|
||||
class="trust-item__impact"
|
||||
[class]="'trust-item__impact trust-item__impact--' + item.impact.toLowerCase()"
|
||||
>
|
||||
Impact: {{ item.impact }}
|
||||
</span>
|
||||
<span class="trust-item__detail">{{ item.detail }}</span>
|
||||
</a>
|
||||
@@ -90,7 +98,7 @@ interface FailureItem {
|
||||
<ul class="list">
|
||||
@for (decision of impactedDecisions; track decision.id) {
|
||||
<li>
|
||||
<a [routerLink]="'/release-control/approvals'" [queryParams]="{ releaseId: decision.id }">{{ decision.name }}</a>
|
||||
<a [routerLink]="'/releases/approvals'" [queryParams]="{ releaseId: decision.id }">{{ decision.name }}</a>
|
||||
<span>{{ decision.reason }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
@@ -115,13 +123,13 @@ interface FailureItem {
|
||||
<section class="panel" aria-label="Drilldowns">
|
||||
<h2>Drilldowns</h2>
|
||||
<div class="drilldowns">
|
||||
<a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
|
||||
<a routerLink="/platform-ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
|
||||
<a routerLink="/platform-ops/data-integrity/integration-connectivity">Integration Connectivity</a>
|
||||
<a routerLink="/platform-ops/data-integrity/dlq">DLQ and Replays</a>
|
||||
<a routerLink="/platform-ops/data-integrity/slos">Data Quality SLOs</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/platform/ops/data-integrity/scan-pipeline">Scan Pipeline Health</a>
|
||||
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Reachability Ingest Health</a>
|
||||
<a routerLink="/platform/ops/data-integrity/integration-connectivity">Integration Connectivity</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">DLQ and Replays</a>
|
||||
<a routerLink="/platform/ops/data-integrity/slos">Data Quality SLOs</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -239,6 +247,29 @@ interface FailureItem {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.trust-item__impact {
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.trust-item__impact--blocking {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.trust-item__impact--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.trust-item__impact--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -291,37 +322,42 @@ export class DataIntegrityOverviewComponent {
|
||||
{
|
||||
id: 'feeds',
|
||||
label: 'Feeds Freshness',
|
||||
status: 'warn',
|
||||
state: 'warn',
|
||||
impact: 'BLOCKING',
|
||||
detail: 'NVD feed stale by 3h 12m',
|
||||
route: '/platform-ops/data-integrity/feeds-freshness',
|
||||
route: '/platform/ops/data-integrity/feeds-freshness',
|
||||
},
|
||||
{
|
||||
id: 'scan',
|
||||
label: 'SBOM Pipeline',
|
||||
status: 'ok',
|
||||
state: 'ok',
|
||||
impact: 'INFO',
|
||||
detail: 'Nightly rescan completed',
|
||||
route: '/platform-ops/data-integrity/scan-pipeline',
|
||||
route: '/platform/ops/data-integrity/scan-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'reachability',
|
||||
label: 'Reachability Ingest',
|
||||
status: 'warn',
|
||||
state: 'warn',
|
||||
impact: 'DEGRADED',
|
||||
detail: 'Runtime backlog elevated',
|
||||
route: '/platform-ops/data-integrity/reachability-ingest',
|
||||
route: '/platform/ops/data-integrity/reachability-ingest',
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
label: 'Integrations',
|
||||
status: 'ok',
|
||||
state: 'ok',
|
||||
impact: 'INFO',
|
||||
detail: 'Core connectors are reachable',
|
||||
route: '/platform-ops/data-integrity/integration-connectivity',
|
||||
route: '/platform/ops/data-integrity/integration-connectivity',
|
||||
},
|
||||
{
|
||||
id: 'dlq',
|
||||
label: 'DLQ',
|
||||
status: 'warn',
|
||||
state: 'warn',
|
||||
impact: 'DEGRADED',
|
||||
detail: '3 items pending replay',
|
||||
route: '/platform-ops/data-integrity/dlq',
|
||||
route: '/platform/ops/data-integrity/dlq',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -343,19 +379,19 @@ export class DataIntegrityOverviewComponent {
|
||||
id: 'failure-nvd',
|
||||
title: 'NVD sync lag',
|
||||
detail: 'Feed lag exceeds SLA for release-critical path.',
|
||||
route: '/platform-ops/data-integrity/feeds-freshness',
|
||||
route: '/platform/ops/data-integrity/feeds-freshness',
|
||||
},
|
||||
{
|
||||
id: 'failure-runtime',
|
||||
title: 'Runtime ingest backlog',
|
||||
detail: 'Runtime source queue depth is increasing.',
|
||||
route: '/platform-ops/data-integrity/reachability-ingest',
|
||||
route: '/platform/ops/data-integrity/reachability-ingest',
|
||||
},
|
||||
{
|
||||
id: 'failure-dlq',
|
||||
title: 'DLQ replay queue',
|
||||
detail: 'Pending replay items block confidence for approvals.',
|
||||
route: '/platform-ops/data-integrity/dlq',
|
||||
route: '/platform/ops/data-integrity/dlq',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ interface SloRow {
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/administration/system">Open System SLO Monitoring</a>
|
||||
<a routerLink="/release-control/approvals">Open impacted approvals</a>
|
||||
<a routerLink="/releases/approvals">Open impacted approvals</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -160,3 +160,4 @@ export class DataQualitySlosPageComponent {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ interface DlqItem {
|
||||
<td>{{ item.payload }}</td>
|
||||
<td>{{ item.age }}</td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform-ops/dead-letter">Replay</a>
|
||||
<a routerLink="/platform-ops/dead-letter">View</a>
|
||||
<a routerLink="/platform-ops/data-integrity/nightly-ops">Link job</a>
|
||||
<a routerLink="/platform/ops/dead-letter">Replay</a>
|
||||
<a routerLink="/platform/ops/dead-letter">View</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Link job</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
@@ -76,7 +76,7 @@ interface DlqItem {
|
||||
</div>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform-ops/dead-letter">Open Dead Letter</a>
|
||||
<a routerLink="/platform/ops/dead-letter">Open Dead Letter</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -216,3 +216,4 @@ export class DlqReplaysPageComponent {
|
||||
this.items.filter((item) => item.bucketId === this.selectedBucketId())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,13 @@ interface FeedRow {
|
||||
</table>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform-ops/feeds">Open Feeds and AirGap Ops</a>
|
||||
<a routerLink="/platform-ops/feeds/locks">Apply Version Lock</a>
|
||||
<a routerLink="/platform-ops/feeds/mirror">Retry source sync</a>
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']">Open Feeds & Airgap</a>
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'version-locks' }">
|
||||
Apply Version Lock
|
||||
</a>
|
||||
<a [routerLink]="['/platform/ops/feeds-airgap']" [queryParams]="{ tab: 'feed-mirrors' }">
|
||||
Retry source sync
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -160,3 +164,4 @@ export class FeedsFreshnessPageComponent {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ interface ConnectorRow {
|
||||
<td>{{ row.dependentPipelines }}</td>
|
||||
<td>{{ row.impact }}</td>
|
||||
<td class="actions">
|
||||
<a routerLink="/integrations">Open Detail</a>
|
||||
<a routerLink="/integrations">Test</a>
|
||||
<a routerLink="/platform-ops/data-integrity/nightly-ops">View dependent jobs</a>
|
||||
<a routerLink="/release-control/approvals">View impacted approvals</a>
|
||||
<a routerLink="/platform/integrations">Open Detail</a>
|
||||
<a routerLink="/platform/integrations">Test</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">View dependent jobs</a>
|
||||
<a routerLink="/releases/approvals">View impacted approvals</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -52,7 +52,7 @@ interface ConnectorRow {
|
||||
</table>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/integrations">Open Integrations Hub</a>
|
||||
<a routerLink="/platform/integrations">Open Integrations Hub</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -174,3 +174,5 @@ export class IntegrationConnectivityPageComponent {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ interface AffectedItem {
|
||||
|
||||
<section class="panel">
|
||||
<h2>Integration Reference</h2>
|
||||
<a routerLink="/integrations">Jenkins connector (job trigger source)</a>
|
||||
<a routerLink="/platform/integrations">Jenkins connector (job trigger source)</a>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
@@ -44,10 +44,10 @@ interface AffectedItem {
|
||||
</section>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/release-control/approvals">Open impacted approvals</a>
|
||||
<a routerLink="/release-control/bundles">Open bundles</a>
|
||||
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/platform-ops/orchestrator/jobs">Open logs</a>
|
||||
<a routerLink="/releases/approvals">Open impacted approvals</a>
|
||||
<a routerLink="/releases/versions">Open bundles</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/platform/ops/orchestrator/jobs">Open logs</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -149,3 +149,5 @@ export class DataIntegrityJobRunDetailPageComponent implements OnInit, OnDestroy
|
||||
this.breadcrumbService.clearContextCrumbs();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -67,11 +67,11 @@ interface NightlyJobRow {
|
||||
</td>
|
||||
<td>{{ row.impact }}</td>
|
||||
<td class="actions">
|
||||
<a [routerLink]="['/platform-ops/data-integrity/nightly-ops', row.runId]">View Run</a>
|
||||
<a routerLink="/platform-ops/scheduler/runs">Open Scheduler</a>
|
||||
<a routerLink="/platform-ops/orchestrator/jobs">Open Orchestrator</a>
|
||||
<a routerLink="/integrations">Open Integration</a>
|
||||
<a routerLink="/platform-ops/dead-letter">Open DLQ</a>
|
||||
<a [routerLink]="['/platform/ops/data-integrity/nightly-ops', row.runId]">View Run</a>
|
||||
<a routerLink="/platform/ops/scheduler/runs">Open Scheduler</a>
|
||||
<a routerLink="/platform/ops/orchestrator/jobs">Open Orchestrator</a>
|
||||
<a routerLink="/platform/integrations">Open Integration</a>
|
||||
<a routerLink="/platform/ops/dead-letter">Open DLQ</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -250,3 +250,4 @@ export class NightlyOpsReportPageComponent {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -53,9 +53,9 @@ interface IngestRow {
|
||||
</table>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform-ops/agents">Open Agents</a>
|
||||
<a routerLink="/platform-ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/release-control/approvals">Open impacted approvals</a>
|
||||
<a routerLink="/platform/ops/agents">Open Agents</a>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ bucket</a>
|
||||
<a routerLink="/releases/approvals">Open impacted approvals</a>
|
||||
</footer>
|
||||
</div>
|
||||
`,
|
||||
@@ -198,3 +198,5 @@ export class ReachabilityIngestHealthPageComponent {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ interface Stage {
|
||||
</section>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform-ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform-ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/integrations">Integrations</a>
|
||||
<a routerLink="/platform/ops/data-integrity/nightly-ops">Nightly Ops Report</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Feeds Freshness</a>
|
||||
<a routerLink="/platform/integrations">Integrations</a>
|
||||
<a routerLink="/security-risk/findings">Security Findings</a>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -173,3 +173,4 @@ export class ScanPipelineHealthPageComponent {
|
||||
readonly affectedEnvironments = 3;
|
||||
readonly blockedApprovals = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { ChangeDetectionStrategy, Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-feeds-airgap-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="feeds-offline">
|
||||
<header class="feeds-offline__header">
|
||||
<div>
|
||||
<h1>Feeds & Airgap</h1>
|
||||
<p>
|
||||
Feed mirror freshness, airgap bundle workflows, and version lock controls for deterministic
|
||||
release decisions.
|
||||
</p>
|
||||
</div>
|
||||
<div class="feeds-offline__actions">
|
||||
<a routerLink="/platform/integrations/feeds">Configure Sources</a>
|
||||
<button type="button">Sync Now</button>
|
||||
<button type="button">Import Airgap Bundle</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button type="button" [class.active]="tab() === 'feed-mirrors'" (click)="tab.set('feed-mirrors')">
|
||||
Feed Mirrors
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'airgap-bundles'" (click)="tab.set('airgap-bundles')">
|
||||
Airgap Bundles
|
||||
</button>
|
||||
<button type="button" [class.active]="tab() === 'version-locks'" (click)="tab.set('version-locks')">
|
||||
Version Locks
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="summary">
|
||||
<span>Mirrors 2</span>
|
||||
<span>Synced 1</span>
|
||||
<span>Stale 1</span>
|
||||
<span>Errors 1</span>
|
||||
<span>Storage 12.4 GB</span>
|
||||
</section>
|
||||
|
||||
<section class="status-banner">
|
||||
<strong>Feeds degraded</strong>
|
||||
<span>Impact: BLOCKING</span>
|
||||
<span>Mode: last-known-good snapshot (read-only)</span>
|
||||
<code>corr-feed-8841</code>
|
||||
<button type="button">Retry</button>
|
||||
</section>
|
||||
|
||||
<article class="panel">
|
||||
@if (tab() === 'feed-mirrors') {
|
||||
<table aria-label="Feed mirrors table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Source</th>
|
||||
<th>Last Sync</th>
|
||||
<th>Freshness</th>
|
||||
<th>Status</th>
|
||||
<th>Impact</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>NVD Mirror</td>
|
||||
<td>https://nvd.nist.gov</td>
|
||||
<td>08:10 UTC</td>
|
||||
<td>Stale 3h12m</td>
|
||||
<td>WARN</td>
|
||||
<td>BLOCKING</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>OSV Mirror</td>
|
||||
<td>https://osv.dev</td>
|
||||
<td>11:58 UTC</td>
|
||||
<td>Fresh</td>
|
||||
<td>OK</td>
|
||||
<td>INFO</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
@if (tab() === 'airgap-bundles') {
|
||||
<p>Offline import/export workflows and bundle verification controls.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/platform/ops/offline-kit">Open Offline Kit Operations</a>
|
||||
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
|
||||
</div>
|
||||
}
|
||||
@if (tab() === 'version-locks') {
|
||||
<p>Freeze upstream feed inputs used by promotion gates and replay evidence.</p>
|
||||
<div class="panel__links">
|
||||
<a routerLink="/platform/setup/feed-policy">Open Feed Policy</a>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open Freshness Lens</a>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.feeds-offline {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.feeds-offline__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.feeds-offline__header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feeds-offline__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.feeds-offline__actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feeds-offline__actions a,
|
||||
.feeds-offline__actions button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.73rem;
|
||||
padding: 0.28rem 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary span {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.12rem 0.45rem;
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
border: 1px solid var(--color-status-warning-text);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
padding: 0.45rem 0.55rem;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
|
||||
.status-banner code {
|
||||
font-size: 0.68rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.status-banner button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.67rem;
|
||||
padding: 0.08rem 0.34rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.4rem;
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel p {
|
||||
margin: 0;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.panel__links {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel a {
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformFeedsAirgapPageComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly tab = signal<FeedsOfflineTab>('feed-mirrors');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.queryParamMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((params) => {
|
||||
const requested = params.get('tab');
|
||||
if (
|
||||
requested === 'feed-mirrors' ||
|
||||
requested === 'airgap-bundles' ||
|
||||
requested === 'version-locks'
|
||||
) {
|
||||
this.tab.set(requested);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers';
|
||||
type JobImpact = 'BLOCKING' | 'DEGRADED' | 'INFO';
|
||||
|
||||
interface JobDefinitionRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
lastRun: string;
|
||||
health: 'OK' | 'WARN' | 'DLQ';
|
||||
}
|
||||
|
||||
interface JobRunRow {
|
||||
id: string;
|
||||
job: string;
|
||||
status: 'RUNNING' | 'COMPLETED' | 'FAILED' | 'DEAD-LETTER';
|
||||
startedAt: string;
|
||||
duration: string;
|
||||
impact: JobImpact;
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
interface ScheduleRow {
|
||||
id: string;
|
||||
name: string;
|
||||
cron: string;
|
||||
nextRun: string;
|
||||
lastStatus: 'OK' | 'WARN' | 'FAIL';
|
||||
}
|
||||
|
||||
interface DeadLetterRow {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
job: string;
|
||||
error: string;
|
||||
retryable: 'YES' | 'NO';
|
||||
impact: JobImpact;
|
||||
correlationId: string;
|
||||
}
|
||||
|
||||
interface WorkerRow {
|
||||
id: string;
|
||||
name: string;
|
||||
queue: string;
|
||||
state: 'HEALTHY' | 'DEGRADED';
|
||||
capacity: string;
|
||||
heartbeat: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-jobs-queues-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="jobs-queues">
|
||||
<header class="jobs-queues__header">
|
||||
<div>
|
||||
<h1>Jobs & Queues</h1>
|
||||
<p>
|
||||
Unified operator surface for orchestrator jobs, scheduler runs, schedules,
|
||||
dead letters, and worker fleet posture.
|
||||
</p>
|
||||
</div>
|
||||
<div class="jobs-queues__actions">
|
||||
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/platform/ops/doctor">Run Diagnostics</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" aria-label="Jobs and queues tabs">
|
||||
<button type="button" [class.active]="tab() === 'jobs'" (click)="tab.set('jobs')">Jobs</button>
|
||||
<button type="button" [class.active]="tab() === 'runs'" (click)="tab.set('runs')">Runs</button>
|
||||
<button type="button" [class.active]="tab() === 'schedules'" (click)="tab.set('schedules')">Schedules</button>
|
||||
<button type="button" [class.active]="tab() === 'dead-letters'" (click)="tab.set('dead-letters')">Dead Letters</button>
|
||||
<button type="button" [class.active]="tab() === 'workers'" (click)="tab.set('workers')">Workers</button>
|
||||
</nav>
|
||||
|
||||
<section class="kpis" aria-label="Queue summary">
|
||||
<span>Running {{ runsByStatus('RUNNING') }}</span>
|
||||
<span>Failed {{ runsByStatus('FAILED') }}</span>
|
||||
<span>Dead-letter {{ runsByStatus('DEAD-LETTER') }}</span>
|
||||
<span>Schedules {{ schedules.length }}</span>
|
||||
<span>Workers {{ workers.length }}</span>
|
||||
</section>
|
||||
|
||||
<section class="filters">
|
||||
<label>
|
||||
Search
|
||||
<input type="search" placeholder="Job id, run id, correlation id" />
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select>
|
||||
<option>All</option>
|
||||
<option>RUNNING</option>
|
||||
<option>FAILED</option>
|
||||
<option>DEAD-LETTER</option>
|
||||
<option>COMPLETED</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select>
|
||||
<option>All</option>
|
||||
<option>security</option>
|
||||
<option>supply</option>
|
||||
<option>evidence</option>
|
||||
<option>feeds</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
@if (tab() === 'jobs') {
|
||||
<section class="table-wrap">
|
||||
<table aria-label="Job definitions table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Type</th>
|
||||
<th>Last Run</th>
|
||||
<th>Health</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of jobs; track row.id) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.type }}</td>
|
||||
<td>{{ row.lastRun }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.health.toLowerCase()">{{ row.health }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Run Now</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (tab() === 'runs') {
|
||||
<section class="table-wrap">
|
||||
<table aria-label="Job runs table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run ID</th>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
<th>Impact</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of runs; track row.id) {
|
||||
<tr>
|
||||
<td>{{ row.id }}</td>
|
||||
<td>{{ row.job }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.status.toLowerCase().replace('-', '')">{{ row.status }}</span></td>
|
||||
<td>{{ row.startedAt }}</td>
|
||||
<td>{{ row.duration }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (tab() === 'schedules') {
|
||||
<section class="table-wrap">
|
||||
<table aria-label="Schedules table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schedule</th>
|
||||
<th>Cron</th>
|
||||
<th>Next Run</th>
|
||||
<th>Last Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of schedules; track row.id) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.cron }}</td>
|
||||
<td>{{ row.nextRun }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.lastStatus.toLowerCase()">{{ row.lastStatus }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">Edit</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Pause</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (tab() === 'dead-letters') {
|
||||
<section class="table-wrap">
|
||||
<table aria-label="Dead letters table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Job</th>
|
||||
<th>Error</th>
|
||||
<th>Retryable</th>
|
||||
<th>Impact</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of deadLetters; track row.id) {
|
||||
<tr>
|
||||
<td>{{ row.timestamp }}</td>
|
||||
<td>{{ row.job }}</td>
|
||||
<td>{{ row.error }}</td>
|
||||
<td>{{ row.retryable }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">Replay</a>
|
||||
<button type="button" (click)="copyCorrelationId(row.correlationId)">Copy CorrID</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (tab() === 'workers') {
|
||||
<section class="table-wrap">
|
||||
<table aria-label="Workers table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Queue</th>
|
||||
<th>State</th>
|
||||
<th>Capacity</th>
|
||||
<th>Last Heartbeat</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of workers; track row.id) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.queue }}</td>
|
||||
<td><span class="pill" [class]="'pill pill--' + row.state.toLowerCase()">{{ row.state }}</span></td>
|
||||
<td>{{ row.capacity }}</td>
|
||||
<td>{{ row.heartbeat }}</td>
|
||||
<td class="actions">
|
||||
<a routerLink="/platform/ops/jobs-queues">View</a>
|
||||
<a routerLink="/platform/ops/jobs-queues">Drain</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="drawer">
|
||||
<h2>Context</h2>
|
||||
@if (tab() === 'jobs') {
|
||||
<p>Jobs define recurring and ad hoc automation units used by release/security/evidence pipelines.</p>
|
||||
}
|
||||
@if (tab() === 'runs') {
|
||||
<p>
|
||||
Active issue: <strong>run-004 is in dead-letter</strong> due to upstream feed rate limiting.
|
||||
Impact: <span class="impact impact--blocking">BLOCKING</span>
|
||||
</p>
|
||||
}
|
||||
@if (tab() === 'schedules') {
|
||||
<p>Schedules control deterministic execution windows and regional workload sequencing.</p>
|
||||
}
|
||||
@if (tab() === 'dead-letters') {
|
||||
<p>
|
||||
Dead-letter triage is linked to release impact.
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open DLQ & Replays</a>
|
||||
</p>
|
||||
}
|
||||
@if (tab() === 'workers') {
|
||||
<p>Worker capacity and health affect queue latency and decision freshness SLAs.</p>
|
||||
}
|
||||
<div class="drawer__links">
|
||||
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
|
||||
<a routerLink="/evidence/audit-log">Open Audit Log</a>
|
||||
<a routerLink="/releases/runs">Impacted Decisions</a>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.jobs-queues {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.jobs-queues__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.jobs-queues__header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.jobs-queues__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.jobs-queues__actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jobs-queues__actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.16rem 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kpis span {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.13rem 0.45rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.55rem;
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.filters input,
|
||||
.filters select {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.28rem 0.4rem;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.45rem 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pill,
|
||||
.impact {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.pill--running,
|
||||
.pill--healthy,
|
||||
.pill--ok {
|
||||
background: var(--color-status-success-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.pill--completed {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.pill--failed,
|
||||
.pill--deadletter,
|
||||
.pill--fail {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.pill--warn,
|
||||
.pill--dlq,
|
||||
.pill--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.impact--blocking {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.impact--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.impact--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.32rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions a,
|
||||
.actions button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.67rem;
|
||||
padding: 0.14rem 0.35rem;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.drawer h2 {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drawer p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.drawer__links {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drawer__links a,
|
||||
.drawer a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.73rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformJobsQueuesPageComponent {
|
||||
readonly tab = signal<JobsQueuesTab>('jobs');
|
||||
|
||||
readonly jobs: JobDefinitionRow[] = [
|
||||
{ id: 'job-def-1', name: 'Container scan', type: 'security', lastRun: '08:03 UTC (2m)', health: 'OK' },
|
||||
{ id: 'job-def-2', name: 'SBOM generation', type: 'supply', lastRun: '07:55 UTC (1m)', health: 'OK' },
|
||||
{ id: 'job-def-3', name: 'Compliance export', type: 'evidence', lastRun: '06:00 UTC (5m)', health: 'OK' },
|
||||
{ id: 'job-def-4', name: 'Vulnerability sync (NVD)', type: 'feeds', lastRun: '05:12 UTC (FAIL)', health: 'DLQ' },
|
||||
];
|
||||
|
||||
readonly runs: JobRunRow[] = [
|
||||
{
|
||||
id: 'run-001',
|
||||
job: 'Container scan',
|
||||
status: 'RUNNING',
|
||||
startedAt: '08:03 UTC',
|
||||
duration: '5m',
|
||||
impact: 'INFO',
|
||||
correlationId: 'corr-run-001',
|
||||
},
|
||||
{
|
||||
id: 'run-002',
|
||||
job: 'SBOM generation',
|
||||
status: 'COMPLETED',
|
||||
startedAt: '07:55 UTC',
|
||||
duration: '1m',
|
||||
impact: 'INFO',
|
||||
correlationId: 'corr-run-002',
|
||||
},
|
||||
{
|
||||
id: 'run-003',
|
||||
job: 'Compliance export',
|
||||
status: 'FAILED',
|
||||
startedAt: '06:00 UTC',
|
||||
duration: '2m',
|
||||
impact: 'DEGRADED',
|
||||
correlationId: 'corr-run-003',
|
||||
},
|
||||
{
|
||||
id: 'run-004',
|
||||
job: 'Vulnerability sync (NVD)',
|
||||
status: 'DEAD-LETTER',
|
||||
startedAt: '05:12 UTC',
|
||||
duration: '-',
|
||||
impact: 'BLOCKING',
|
||||
correlationId: 'corr-dlq-9031',
|
||||
},
|
||||
];
|
||||
|
||||
readonly schedules: ScheduleRow[] = [
|
||||
{ id: 'sch-1', name: 'Nightly supply scan', cron: '0 2 * * *', nextRun: '02:00 UTC', lastStatus: 'OK' },
|
||||
{ id: 'sch-2', name: 'Advisory sync', cron: '*/30 * * * *', nextRun: '22:30 UTC', lastStatus: 'WARN' },
|
||||
{ id: 'sch-3', name: 'Evidence export', cron: '0 6 * * *', nextRun: '06:00 UTC', lastStatus: 'FAIL' },
|
||||
];
|
||||
|
||||
readonly deadLetters: DeadLetterRow[] = [
|
||||
{
|
||||
id: 'dlq-1',
|
||||
timestamp: '05:12 UTC',
|
||||
job: 'Vulnerability sync (NVD)',
|
||||
error: 'HTTP 429 rate limit',
|
||||
retryable: 'YES',
|
||||
impact: 'BLOCKING',
|
||||
correlationId: 'corr-dlq-9031',
|
||||
},
|
||||
{
|
||||
id: 'dlq-2',
|
||||
timestamp: '05:08 UTC',
|
||||
job: 'Reachability ingest',
|
||||
error: 'Runtime timeout',
|
||||
retryable: 'YES',
|
||||
impact: 'DEGRADED',
|
||||
correlationId: 'corr-dlq-9030',
|
||||
},
|
||||
{
|
||||
id: 'dlq-3',
|
||||
timestamp: '04:55 UTC',
|
||||
job: 'Evidence export',
|
||||
error: 'S3 access denied',
|
||||
retryable: 'NO',
|
||||
impact: 'BLOCKING',
|
||||
correlationId: 'corr-dlq-9027',
|
||||
},
|
||||
];
|
||||
|
||||
readonly workers: WorkerRow[] = [
|
||||
{ id: 'wrk-1', name: 'worker-east-01', queue: 'security', state: 'HEALTHY', capacity: '8/10', heartbeat: '5s ago' },
|
||||
{ id: 'wrk-2', name: 'worker-east-02', queue: 'feeds', state: 'DEGRADED', capacity: '10/10', heartbeat: '24s ago' },
|
||||
{ id: 'wrk-3', name: 'worker-eu-01', queue: 'supply', state: 'HEALTHY', capacity: '6/10', heartbeat: '7s ago' },
|
||||
];
|
||||
|
||||
runsByStatus(status: JobRunRow['status']): number {
|
||||
return this.runs.filter((row) => row.status === status).length;
|
||||
}
|
||||
|
||||
copyCorrelationId(correlationId: string): void {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(correlationId).catch(() => null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface WorkflowCard {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
route: string;
|
||||
impact: 'BLOCKING' | 'DEGRADED' | 'INFO';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-ops-overview-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="ops-overview">
|
||||
<header class="ops-overview__header">
|
||||
<div>
|
||||
<h1>Platform Ops</h1>
|
||||
<p>
|
||||
Operability workflows for defensible release decisions: data trust, execution control,
|
||||
and service health.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ops-overview__actions">
|
||||
<a routerLink="/platform/ops/doctor">Run Doctor</a>
|
||||
<a routerLink="/evidence/exports">Export Ops Report</a>
|
||||
<button type="button" (click)="refreshed.set(true)">Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="ops-overview__kpis" aria-label="Ops posture snapshot">
|
||||
<article>
|
||||
<h2>Data Trust Score</h2>
|
||||
<p>87</p>
|
||||
<span class="pill pill--warn">WARN</span>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Platform Health</h2>
|
||||
<p>2</p>
|
||||
<span class="pill pill--warn">WARN services</span>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Dead Letter Queue</h2>
|
||||
<p>3</p>
|
||||
<span class="pill pill--degraded">DEGRADED</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__primary">
|
||||
<h2>Primary Workflows</h2>
|
||||
<div class="ops-overview__grid">
|
||||
@for (card of primaryWorkflows; track card.id) {
|
||||
<a class="ops-card" [routerLink]="card.route">
|
||||
<h3>{{ card.title }}</h3>
|
||||
<p>{{ card.description }}</p>
|
||||
<span class="impact" [class]="'impact impact--' + card.impact.toLowerCase()">
|
||||
Impact: {{ card.impact }}
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__secondary">
|
||||
<h2>Secondary Operator Tools</h2>
|
||||
<div class="ops-overview__links">
|
||||
<a routerLink="/platform/ops/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/platform/ops/quotas">Quotas & Limits</a>
|
||||
<a routerLink="/platform/ops/doctor">Diagnostics</a>
|
||||
<a routerLink="/topology/agents">Topology Health</a>
|
||||
<a routerLink="/evidence/capsules">Decision Capsule Stats</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ops-overview__alerts">
|
||||
<h2>Recent Operator Alerts</h2>
|
||||
<ul>
|
||||
<li>
|
||||
NVD feed stale 3h12m
|
||||
<span class="impact impact--blocking">Impact: BLOCKING</span>
|
||||
<a routerLink="/platform/ops/data-integrity/feeds-freshness">Open</a>
|
||||
</li>
|
||||
<li>
|
||||
Runtime ingest backlog
|
||||
<span class="impact impact--degraded">Impact: DEGRADED</span>
|
||||
<a routerLink="/platform/ops/data-integrity/reachability-ingest">Open</a>
|
||||
</li>
|
||||
<li>
|
||||
DLQ replay queue pending
|
||||
<span class="impact impact--degraded">Impact: DEGRADED</span>
|
||||
<a routerLink="/platform/ops/data-integrity/dlq">Open</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@if (refreshed()) {
|
||||
<p class="ops-overview__note">Snapshot refreshed for current scope.</p>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.ops-overview {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.ops-overview__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.ops-overview__header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.ops-overview__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
max-width: 66ch;
|
||||
}
|
||||
|
||||
.ops-overview__actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ops-overview__actions a,
|
||||
.ops-overview__actions button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--color-surface-primary);
|
||||
text-decoration: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ops-overview__kpis {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.ops-overview__kpis article {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.ops-overview__kpis h2 {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.ops-overview__kpis p {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.pill {
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.pill--warn {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.pill--degraded {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.ops-overview__primary h2,
|
||||
.ops-overview__secondary h2,
|
||||
.ops-overview__alerts h2 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ops-overview__grid {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
}
|
||||
|
||||
.ops-card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.7rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ops-card h3 {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.ops-card p {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.impact {
|
||||
width: fit-content;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.impact--blocking {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.impact--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.impact--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.ops-overview__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.ops-overview__links a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.25rem 0.45rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ops-overview__alerts ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ops-overview__alerts li {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.45rem 0.55rem;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.ops-overview__alerts a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ops-overview__note {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformOpsOverviewPageComponent {
|
||||
readonly refreshed = signal(false);
|
||||
|
||||
readonly primaryWorkflows: WorkflowCard[] = [
|
||||
{
|
||||
id: 'data-integrity',
|
||||
title: 'Data Integrity',
|
||||
description: 'Trust signals, blocked decisions, and freshness recovery actions.',
|
||||
route: '/platform/ops/data-integrity',
|
||||
impact: 'BLOCKING',
|
||||
},
|
||||
{
|
||||
id: 'jobs-queues',
|
||||
title: 'Jobs & Queues',
|
||||
description: 'Unified orchestration runs, schedules, dead letters, and workers.',
|
||||
route: '/platform/ops/jobs-queues',
|
||||
impact: 'DEGRADED',
|
||||
},
|
||||
{
|
||||
id: 'health-slo',
|
||||
title: 'Health & SLO',
|
||||
description: 'Service/dependency health and incident timelines with SLO context.',
|
||||
route: '/platform/ops/health-slo',
|
||||
impact: 'INFO',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-home-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="platform-home">
|
||||
<header class="platform-home__header">
|
||||
<div>
|
||||
<h1>Platform</h1>
|
||||
<p>
|
||||
Operate and configure the infrastructure substrate that powers release control,
|
||||
security posture, and evidence-grade decisions.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="platform-home__doors">
|
||||
<a class="door" routerLink="/platform/ops">
|
||||
<h2>Platform Ops</h2>
|
||||
<p>Runtime reliability, pipelines, queues, mirrors, quotas, and diagnostics.</p>
|
||||
<span>Open</span>
|
||||
</a>
|
||||
<a class="door" routerLink="/platform/integrations">
|
||||
<h2>Integrations</h2>
|
||||
<p>Connector health, credentials, scopes, and external dependency observability.</p>
|
||||
<span>Open</span>
|
||||
</a>
|
||||
<a class="door" routerLink="/platform/setup">
|
||||
<h2>Setup</h2>
|
||||
<p>Promotion topology, workflow defaults, templates, and guardrails.</p>
|
||||
<span>Open</span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="platform-home__snapshot">
|
||||
<h2>Status Snapshot</h2>
|
||||
<div class="snapshot-grid">
|
||||
<article>
|
||||
<strong>Health</strong>
|
||||
<span>OK</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Data Integrity</strong>
|
||||
<span>WARN (3 signals)</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Dead Letters</strong>
|
||||
<span>3 pending</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Mirrors</strong>
|
||||
<span>2 stale</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Quotas</strong>
|
||||
<span>72% used</span>
|
||||
</article>
|
||||
<article>
|
||||
<strong>Offline</strong>
|
||||
<span>Online</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="platform-home__actions">
|
||||
<a routerLink="/platform/ops/doctor">Run Diagnostics</a>
|
||||
<a routerLink="/platform/integrations/onboarding/registry">Add Integration</a>
|
||||
<a routerLink="/platform/setup/promotion-paths">Configure Promotion Paths</a>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.platform-home {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.platform-home__header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.platform-home__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.platform-home__doors {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.door {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.7rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.door h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.door p {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.door span {
|
||||
width: fit-content;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-brand-primary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
|
||||
.platform-home__snapshot {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.7rem;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-home__snapshot h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.snapshot-grid {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.snapshot-grid article {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 0.45rem;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.snapshot-grid strong {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.snapshot-grid span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.platform-home__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.platform-home__actions a {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.3rem 0.55rem;
|
||||
text-decoration: none;
|
||||
color: var(--color-brand-primary);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformHomePageComponent {}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface GuardrailRow {
|
||||
domain: string;
|
||||
defaultValue: string;
|
||||
impact: 'BLOCKING' | 'DEGRADED' | 'INFO';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-defaults-guardrails-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Defaults & Guardrails</h1>
|
||||
<p>
|
||||
Configure control-plane defaults that shape promotion behavior, evidence completeness,
|
||||
and degraded-mode policy handling.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article class="card">
|
||||
<h2>Default Controls</h2>
|
||||
<table aria-label="Defaults and guardrails table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Default</th>
|
||||
<th>Impact if Violated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of guardrails; track row.domain) {
|
||||
<tr>
|
||||
<td>{{ row.domain }}</td>
|
||||
<td>{{ row.defaultValue }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.impact.toLowerCase()">{{ row.impact }}</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Global Behaviors</h2>
|
||||
<ul>
|
||||
<li>Require correlation IDs in all degraded/offline error banners.</li>
|
||||
<li>Default export profile includes policy trace and decision evidence.</li>
|
||||
<li>Promotion defaults follow region risk tier unless explicit override exists.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/setup/gate-profiles">Open Gate Profiles</a>
|
||||
<a routerLink="/platform/setup/feed-policy">Open Feed Policy</a>
|
||||
<a routerLink="/platform/ops/data-integrity">Open Data Integrity</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.38rem 0.42rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.impact {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.42rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.impact--blocking {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.impact--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.impact--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupDefaultsGuardrailsPageComponent {
|
||||
readonly guardrails: GuardrailRow[] = [
|
||||
{
|
||||
domain: 'Promotion policy gate',
|
||||
defaultValue: 'Require policy + approvals for prod lanes',
|
||||
impact: 'BLOCKING',
|
||||
},
|
||||
{
|
||||
domain: 'Data integrity gate',
|
||||
defaultValue: 'Warn on degraded reachability coverage below 80%',
|
||||
impact: 'DEGRADED',
|
||||
},
|
||||
{
|
||||
domain: 'Evidence bundle profile',
|
||||
defaultValue: 'Attach decision capsule and audit trace by default',
|
||||
impact: 'INFO',
|
||||
},
|
||||
{
|
||||
domain: 'Feed freshness enforcement',
|
||||
defaultValue: 'Block prod promotions when critical feeds are stale',
|
||||
impact: 'BLOCKING',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface FeedSlaRow {
|
||||
source: string;
|
||||
freshnessSla: string;
|
||||
staleBehavior: string;
|
||||
decisionImpact: 'BLOCKING' | 'DEGRADED' | 'INFO';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-feed-policy-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Feed Policy</h1>
|
||||
<p>
|
||||
Define advisory and VEX feed freshness requirements for promotion decisions.
|
||||
Source connectors are managed in Integrations; mirror operations are managed in Ops.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article class="card">
|
||||
<h2>Freshness SLA</h2>
|
||||
<table aria-label="Feed freshness policy">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Freshness SLA</th>
|
||||
<th>Stale Behavior</th>
|
||||
<th>Decision Impact</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of policies; track row.source) {
|
||||
<tr>
|
||||
<td>{{ row.source }}</td>
|
||||
<td>{{ row.freshnessSla }}</td>
|
||||
<td>{{ row.staleBehavior }}</td>
|
||||
<td><span class="impact" [class]="'impact impact--' + row.decisionImpact.toLowerCase()">{{ row.decisionImpact }}</span></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Promotion Behavior</h2>
|
||||
<ul>
|
||||
<li><strong>Prod promotions:</strong> block when critical feed SLA is violated.</li>
|
||||
<li><strong>Stage promotions:</strong> allow degraded passage with warning and evidence note.</li>
|
||||
<li><strong>Overrides:</strong> require security exception justification and approval chain.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/integrations/feeds">Manage Feed Connectors</a>
|
||||
<a routerLink="/platform/ops/feeds-airgap">Open Feeds & Airgap Operations</a>
|
||||
<a routerLink="/security/advisories-vex">Open Advisories & VEX</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 74ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.38rem 0.42rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.impact {
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1rem 0.42rem;
|
||||
font-size: 0.64rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.impact--blocking {
|
||||
background: var(--color-status-error-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.impact--degraded {
|
||||
background: var(--color-status-warning-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.impact--info {
|
||||
background: var(--color-status-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupFeedPolicyPageComponent {
|
||||
readonly policies: FeedSlaRow[] = [
|
||||
{
|
||||
source: 'NVD',
|
||||
freshnessSla: '1h',
|
||||
staleBehavior: 'Block prod promotions',
|
||||
decisionImpact: 'BLOCKING',
|
||||
},
|
||||
{
|
||||
source: 'GitHub Advisories',
|
||||
freshnessSla: '2h',
|
||||
staleBehavior: 'Warn on stale, allow with evidence note',
|
||||
decisionImpact: 'DEGRADED',
|
||||
},
|
||||
{
|
||||
source: 'VEX Repository',
|
||||
freshnessSla: '6h',
|
||||
staleBehavior: 'Warn when stale',
|
||||
decisionImpact: 'INFO',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface GateProfileRow {
|
||||
name: string;
|
||||
releasePath: string;
|
||||
requirements: string;
|
||||
escalation: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-gate-profiles-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Gate Profiles</h1>
|
||||
<p>
|
||||
Gate profiles define the release control baseline for approvals, policy, data integrity,
|
||||
and evidence completeness at each promotion stage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article class="card">
|
||||
<h2>Profiles</h2>
|
||||
<table aria-label="Gate profiles table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Profile</th>
|
||||
<th>Release Path</th>
|
||||
<th>Requirements</th>
|
||||
<th>Escalation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (profile of profiles; track profile.name) {
|
||||
<tr>
|
||||
<td>{{ profile.name }}</td>
|
||||
<td>{{ profile.releasePath }}</td>
|
||||
<td>{{ profile.requirements }}</td>
|
||||
<td>{{ profile.escalation }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h2>Profile Selection Rules</h2>
|
||||
<ul>
|
||||
<li>Production environments default to <strong>strict-prod</strong>.</li>
|
||||
<li>Canary and stage environments default to <strong>risk-aware</strong>.</li>
|
||||
<li>Hotfix lanes may switch to <strong>expedited-hotfix</strong> with explicit approval.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/platform/setup/workflows-gates">Open Workflows & Gates</a>
|
||||
<a routerLink="/platform/setup/defaults-guardrails">Open Defaults & Guardrails</a>
|
||||
<a routerLink="/security/overview">Open Security Baseline</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 76ch;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.38rem 0.42rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupGateProfilesPageComponent {
|
||||
readonly profiles: GateProfileRow[] = [
|
||||
{
|
||||
name: 'strict-prod',
|
||||
releasePath: 'stage -> prod',
|
||||
requirements: 'Policy pass, approvals, data integrity green, evidence required',
|
||||
escalation: 'Block until resolved',
|
||||
},
|
||||
{
|
||||
name: 'risk-aware',
|
||||
releasePath: 'dev -> stage',
|
||||
requirements: 'Policy pass with degraded tolerance and warning capture',
|
||||
escalation: 'Warn and continue',
|
||||
},
|
||||
{
|
||||
name: 'expedited-hotfix',
|
||||
releasePath: 'stage -> prod-hotfix',
|
||||
requirements: 'Reduced approvals, evidence replay required post-deploy',
|
||||
escalation: 'Manual escalation required',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-home',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-home">
|
||||
<header>
|
||||
<h1>Platform Setup</h1>
|
||||
<p>Configure inventory, promotion defaults, workflow gates, feed policy, and guardrails.</p>
|
||||
</header>
|
||||
|
||||
<div class="readiness">
|
||||
<span>Regions configured: 2</span>
|
||||
<span>Environments: 6</span>
|
||||
<span>Workflows: 3</span>
|
||||
<span>Gate profiles: 3</span>
|
||||
<span>Templates: 3</span>
|
||||
<span>Feed policies: 3</span>
|
||||
<span>Global guardrails: 4</span>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
<article>
|
||||
<h3>Regions & Environments</h3>
|
||||
<p>Region-first setup, risk tiers, and promotion entry controls.</p>
|
||||
<a routerLink="/platform/setup/regions-environments">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Promotion Paths</h3>
|
||||
<p>Graph, rules, and validation of promotion flow constraints.</p>
|
||||
<a routerLink="/platform/setup/promotion-paths">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Workflows & Gates</h3>
|
||||
<p>Workflow, gate profile, and rollback strategy mapping.</p>
|
||||
<a routerLink="/platform/setup/workflows-gates">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Gate Profiles</h3>
|
||||
<p>Dedicated profile library for strict, risk-aware, and expedited lanes.</p>
|
||||
<a routerLink="/platform/setup/gate-profiles">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Release Templates</h3>
|
||||
<p>Release template defaults aligned with run and evidence workflows.</p>
|
||||
<a routerLink="/platform/setup/release-templates">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Feed Policy</h3>
|
||||
<p>Freshness thresholds and staleness behavior for decision gating.</p>
|
||||
<a routerLink="/platform/setup/feed-policy">Open</a>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h3>Defaults & Guardrails</h3>
|
||||
<p>Control-plane defaults for policy impact labels and degraded-mode behavior.</p>
|
||||
<a routerLink="/platform/setup/defaults-guardrails">Open</a>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/topology/overview">Open Topology Posture</a>
|
||||
<a routerLink="/security/overview">Open Security Baseline</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-home {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.setup-home header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-home header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.readiness {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.readiness span {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-primary);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.12rem 0.45rem;
|
||||
}
|
||||
|
||||
.cards article {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cards h3 {
|
||||
margin: 0;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.cards p {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cards a {
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
font-size: 0.74rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupHomeComponent {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface PromotionRule {
|
||||
id: number;
|
||||
from: string;
|
||||
to: string;
|
||||
requirements: string;
|
||||
crossRegion: 'yes' | 'no';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-promotion-paths-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Promotion Paths</h1>
|
||||
<p>Define release movement rules across environments and gate requirements.</p>
|
||||
</header>
|
||||
|
||||
<article class="graph">
|
||||
<h2>Path Map</h2>
|
||||
<p><code>dev --(approvals)--> stage --(policy+ops)--> prod</code></p>
|
||||
</article>
|
||||
|
||||
<article class="rules">
|
||||
<h2>Rules</h2>
|
||||
<table aria-label="Promotion rules">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Rule</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Requirements</th>
|
||||
<th>Cross-region</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (rule of rules; track rule.id) {
|
||||
<tr>
|
||||
<td>{{ rule.id }}</td>
|
||||
<td>{{ rule.from }}</td>
|
||||
<td>{{ rule.to }}</td>
|
||||
<td>{{ rule.requirements }}</td>
|
||||
<td>{{ rule.crossRegion }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/releases/runs">Open Release Runs</a>
|
||||
<a routerLink="/topology/promotion-paths">Open Topology Path View</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.graph,
|
||||
.rules {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.graph h2,
|
||||
.rules h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.graph p {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupPromotionPathsPageComponent {
|
||||
readonly rules: PromotionRule[] = [
|
||||
{ id: 1, from: 'dev', to: 'stage', requirements: 'approvals', crossRegion: 'no' },
|
||||
{ id: 2, from: 'stage', to: 'prod', requirements: 'policy+ops gate', crossRegion: 'no' },
|
||||
{ id: 3, from: 'stage', to: 'prod-canary', requirements: 'risk-aware gate', crossRegion: 'yes' },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface RegionRow {
|
||||
environment: string;
|
||||
riskTier: 'low' | 'medium' | 'high';
|
||||
promotionEntry: 'yes' | 'guarded';
|
||||
status: 'ok' | 'warn';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-regions-environments-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Regions & Environments</h1>
|
||||
<p>
|
||||
Region-first setup inventory used by release workflows, policy gates, and global context
|
||||
selectors.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button">+ Add Region</button>
|
||||
<button type="button">+ Add Environment</button>
|
||||
<button type="button">Import</button>
|
||||
<button type="button">Export</button>
|
||||
</div>
|
||||
|
||||
<article class="region">
|
||||
<h2>Region: us-east</h2>
|
||||
<table aria-label="US East environments">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Environment</th>
|
||||
<th>Risk Tier</th>
|
||||
<th>Promotion Entry</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of usEast; track row.environment) {
|
||||
<tr>
|
||||
<td>{{ row.environment }}</td>
|
||||
<td>{{ row.riskTier }}</td>
|
||||
<td>{{ row.promotionEntry }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="region">
|
||||
<h2>Region: eu-west</h2>
|
||||
<table aria-label="EU West environments">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Environment</th>
|
||||
<th>Risk Tier</th>
|
||||
<th>Promotion Entry</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of euWest; track row.environment) {
|
||||
<tr>
|
||||
<td>{{ row.environment }}</td>
|
||||
<td>{{ row.riskTier }}</td>
|
||||
<td>{{ row.promotionEntry }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/topology/environments">Open Topology Environment Posture</a>
|
||||
<a routerLink="/security/overview">Open Security Policy Baseline</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.region {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.region h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupRegionsEnvironmentsPageComponent {
|
||||
readonly usEast: RegionRow[] = [
|
||||
{ environment: 'dev-us-east', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
|
||||
{ environment: 'stage-us-east', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
|
||||
{ environment: 'prod-us-east', riskTier: 'high', promotionEntry: 'guarded', status: 'warn' },
|
||||
];
|
||||
|
||||
readonly euWest: RegionRow[] = [
|
||||
{ environment: 'dev-eu-west', riskTier: 'low', promotionEntry: 'yes', status: 'ok' },
|
||||
{ environment: 'stage-eu-west', riskTier: 'medium', promotionEntry: 'yes', status: 'ok' },
|
||||
{ environment: 'prod-eu-west', riskTier: 'high', promotionEntry: 'guarded', status: 'ok' },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface TemplateRow {
|
||||
name: string;
|
||||
releaseType: 'standard' | 'hotfix';
|
||||
gateProfile: string;
|
||||
evidencePack: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-release-templates-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Release Templates</h1>
|
||||
<p>Template defaults for release creation, gating, and evidence export requirements.</p>
|
||||
</header>
|
||||
|
||||
<article class="templates">
|
||||
<table aria-label="Release templates">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Release Type</th>
|
||||
<th>Gate Profile</th>
|
||||
<th>Evidence Pack</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of templates; track row.name) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.releaseType }}</td>
|
||||
<td>{{ row.gateProfile }}</td>
|
||||
<td>{{ row.evidencePack }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/releases/versions/new">Create Release from Template</a>
|
||||
<a routerLink="/evidence/exports">View Evidence Export Formats</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.templates {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupReleaseTemplatesPageComponent {
|
||||
readonly templates: TemplateRow[] = [
|
||||
{
|
||||
name: 'standard-regional',
|
||||
releaseType: 'standard',
|
||||
gateProfile: 'strict-prod',
|
||||
evidencePack: 'decision-capsule-v3',
|
||||
},
|
||||
{
|
||||
name: 'canary-regional',
|
||||
releaseType: 'standard',
|
||||
gateProfile: 'risk-aware',
|
||||
evidencePack: 'decision-capsule-canary',
|
||||
},
|
||||
{
|
||||
name: 'hotfix-expedited',
|
||||
releaseType: 'hotfix',
|
||||
gateProfile: 'expedited-hotfix',
|
||||
evidencePack: 'decision-capsule-hotfix',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface WorkflowRow {
|
||||
name: string;
|
||||
path: string;
|
||||
gateProfile: string;
|
||||
rollback: 'auto' | 'manual';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-platform-setup-workflows-gates-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-page">
|
||||
<header>
|
||||
<h1>Workflows & Gates</h1>
|
||||
<p>
|
||||
Maintain workflow catalog, gate profiles, and rollback defaults for each release path.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article class="catalog">
|
||||
<h2>Workflow Catalog</h2>
|
||||
<table aria-label="Workflow catalog">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Gate Profile</th>
|
||||
<th>Rollback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of workflows; track row.name) {
|
||||
<tr>
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.path }}</td>
|
||||
<td>{{ row.gateProfile }}</td>
|
||||
<td>{{ row.rollback }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="profiles">
|
||||
<h2>Gate Profiles</h2>
|
||||
<ul>
|
||||
<li><strong>strict-prod:</strong> blocks stale feeds and unknown runtime reachability.</li>
|
||||
<li><strong>risk-aware:</strong> allows degraded posture with explicit warnings.</li>
|
||||
<li><strong>expedited-hotfix:</strong> reduced approvals with post-deploy evidence requirement.</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/security/overview">Open Security Policy Baseline</a>
|
||||
<a routerLink="/topology/workflows">Open Topology Workflow Inventory</a>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-page {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
header p {
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.catalog,
|
||||
.profiles {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem;
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.catalog h2,
|
||||
.profiles h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
padding: 0.35rem 0.4rem;
|
||||
text-align: left;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.profiles ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.76rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class PlatformSetupWorkflowsGatesPageComponent {
|
||||
readonly workflows: WorkflowRow[] = [
|
||||
{
|
||||
name: 'standard-blue-green',
|
||||
path: 'dev -> stage -> prod',
|
||||
gateProfile: 'strict-prod',
|
||||
rollback: 'auto',
|
||||
},
|
||||
{
|
||||
name: 'canary-regional',
|
||||
path: 'stage -> prod-canary -> prod',
|
||||
gateProfile: 'risk-aware',
|
||||
rollback: 'manual',
|
||||
},
|
||||
{
|
||||
name: 'hotfix-fast-track',
|
||||
path: 'stage -> prod',
|
||||
gateProfile: 'expedited-hotfix',
|
||||
rollback: 'manual',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const PLATFORM_SETUP_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Platform Setup',
|
||||
data: { breadcrumb: 'Setup' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-home.component').then((m) => m.PlatformSetupHomeComponent),
|
||||
},
|
||||
{
|
||||
path: 'regions-environments',
|
||||
title: 'Setup Regions & Environments',
|
||||
data: { breadcrumb: 'Regions & Environments' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-regions-environments-page.component').then(
|
||||
(m) => m.PlatformSetupRegionsEnvironmentsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'promotion-paths',
|
||||
title: 'Setup Promotion Paths',
|
||||
data: { breadcrumb: 'Promotion Paths' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-promotion-paths-page.component').then(
|
||||
(m) => m.PlatformSetupPromotionPathsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'workflows-gates',
|
||||
title: 'Setup Workflows & Gates',
|
||||
data: { breadcrumb: 'Workflows & Gates' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-workflows-gates-page.component').then(
|
||||
(m) => m.PlatformSetupWorkflowsGatesPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'release-templates',
|
||||
title: 'Release Templates',
|
||||
data: { breadcrumb: 'Release Templates' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-release-templates-page.component').then(
|
||||
(m) => m.PlatformSetupReleaseTemplatesPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'feed-policy',
|
||||
title: 'Feed Policy',
|
||||
data: { breadcrumb: 'Feed Policy' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-feed-policy-page.component').then(
|
||||
(m) => m.PlatformSetupFeedPolicyPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'gate-profiles',
|
||||
title: 'Gate Profiles',
|
||||
data: { breadcrumb: 'Gate Profiles' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-gate-profiles-page.component').then(
|
||||
(m) => m.PlatformSetupGateProfilesPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'defaults-guardrails',
|
||||
title: 'Defaults & Guardrails',
|
||||
data: { breadcrumb: 'Defaults & Guardrails' },
|
||||
loadComponent: () =>
|
||||
import('./platform-setup-defaults-guardrails-page.component').then(
|
||||
(m) => m.PlatformSetupDefaultsGuardrailsPageComponent,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'defaults',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'defaults-guardrails',
|
||||
},
|
||||
{
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
loadComponent: () =>
|
||||
import('../../settings/trust/trust-settings-page.component').then(
|
||||
(m) => m.TrustSettingsPageComponent,
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
<!-- Header -->
|
||||
<header class="detail-header">
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/approvals" class="back-link">Approvals</a>
|
||||
<a routerLink="/releases/approvals" class="back-link">Approvals</a>
|
||||
<span class="separator">/</span>
|
||||
<span>{{ approval()!.releaseName }}</span>
|
||||
</div>
|
||||
@@ -255,10 +255,19 @@ import {
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
} @else if (store.error()) {
|
||||
<div class="error-state">
|
||||
<p>Failed to load approval details.</p>
|
||||
<p class="error-detail">{{ store.error() }}</p>
|
||||
<div class="error-actions">
|
||||
<button class="btn btn-secondary" type="button" (click)="retryLoad()">Retry</button>
|
||||
<a routerLink="/releases/approvals" class="btn btn-secondary">Back to Queue</a>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="error-state">
|
||||
<p>Approval not found</p>
|
||||
<a routerLink="/approvals" class="btn btn-secondary">Back to Queue</a>
|
||||
<a routerLink="/releases/approvals" class="btn btn-secondary">Back to Queue</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -781,6 +790,18 @@ import {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.error-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -859,6 +880,13 @@ export class ApprovalDetailComponent implements OnInit, OnDestroy {
|
||||
this.cancelAction();
|
||||
}
|
||||
|
||||
retryLoad(): void {
|
||||
const id = this.route.snapshot.paramMap.get('id');
|
||||
if (id) {
|
||||
this.store.loadApproval(id);
|
||||
}
|
||||
}
|
||||
|
||||
isExpiringSoon(expiresAt: string): boolean {
|
||||
const hoursUntilExpiry = (new Date(expiresAt).getTime() - Date.now()) / 3600000;
|
||||
return hoursUntilExpiry < 4;
|
||||
|
||||
@@ -127,6 +127,7 @@ export class ApprovalStore {
|
||||
loadApproval(id: string): void {
|
||||
this._loading.set(true);
|
||||
this._error.set(null);
|
||||
this._selectedApproval.set(null);
|
||||
|
||||
this.api.getApproval(id).subscribe({
|
||||
next: (approval) => {
|
||||
@@ -134,6 +135,7 @@ export class ApprovalStore {
|
||||
this._loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this._selectedApproval.set(null);
|
||||
this._error.set(err.message || 'Failed to load approval');
|
||||
this._loading.set(false);
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user