Restore live platform compatibility contracts
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class AocCompatibilityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapAocCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/aoc")
|
||||
.WithTags("AOC Compatibility")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.AocVerify))
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/metrics", (
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? tenantId,
|
||||
[FromQuery] int? windowMinutes,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenant = ResolveTenant(httpContext, tenantId);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_required" });
|
||||
}
|
||||
|
||||
var effectiveWindowMinutes = windowMinutes is > 0 ? windowMinutes.Value : 1440;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
passCount = 12847,
|
||||
failCount = 23,
|
||||
totalCount = 12870,
|
||||
passRate = 0.9982,
|
||||
recentViolations = BuildViolationSummaries(now),
|
||||
ingestThroughput = new
|
||||
{
|
||||
docsPerMinute = 8.9,
|
||||
avgLatencyMs = 145,
|
||||
p95LatencyMs = 312,
|
||||
queueDepth = 3,
|
||||
errorRate = 0.18
|
||||
},
|
||||
timeWindow = new
|
||||
{
|
||||
start = now.AddMinutes(-effectiveWindowMinutes).ToString("O", CultureInfo.InvariantCulture),
|
||||
end = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
durationMinutes = effectiveWindowMinutes
|
||||
}
|
||||
});
|
||||
})
|
||||
.WithName("AocCompatibility.GetMetrics");
|
||||
|
||||
group.MapPost("/verify", (
|
||||
HttpContext httpContext,
|
||||
AocVerifyRequest request,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenant = ResolveTenant(httpContext, request.TenantId);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_required" });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
verificationId = $"verify-{tenant}-{now:yyyyMMddHHmmss}",
|
||||
status = "partial",
|
||||
checkedCount = Math.Clamp(request.Limit ?? 250, 1, 1000),
|
||||
passedCount = 247,
|
||||
failedCount = 3,
|
||||
violations = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
documentId = "sbom-nginx-prod",
|
||||
violationCode = "AOC-PROV-001",
|
||||
field = "provenance.digest",
|
||||
expected = "signed",
|
||||
actual = "missing",
|
||||
provenance = new
|
||||
{
|
||||
sourceId = "docker-hub",
|
||||
ingestedAt = now.AddMinutes(-20).ToString("O", CultureInfo.InvariantCulture),
|
||||
digest = "sha256:4fdb5e6a31a80f0d",
|
||||
sourceType = "registry",
|
||||
sourceUrl = "docker.io/library/nginx:1.27.4",
|
||||
submitter = "scanner-agent-01"
|
||||
}
|
||||
}
|
||||
},
|
||||
completedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("AocCompatibility.Verify");
|
||||
|
||||
group.MapGet("/compliance/dashboard", (
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenant = ResolveTenant(httpContext, tenantId);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_required" });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
metrics = new
|
||||
{
|
||||
guardViolations = new
|
||||
{
|
||||
count = 23,
|
||||
percentage = 0.18,
|
||||
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["schema_invalid"] = 9,
|
||||
["missing_required_fields"] = 8,
|
||||
["hash_mismatch"] = 6
|
||||
},
|
||||
trend = "stable"
|
||||
},
|
||||
provenanceCompleteness = new
|
||||
{
|
||||
percentage = 99.1,
|
||||
recordsWithValidHash = 12755,
|
||||
totalRecords = 12870,
|
||||
trend = "up"
|
||||
},
|
||||
deduplicationRate = new
|
||||
{
|
||||
percentage = 12.4,
|
||||
duplicatesDetected = 1595,
|
||||
totalIngested = 12870,
|
||||
trend = "stable"
|
||||
},
|
||||
ingestionLatency = new
|
||||
{
|
||||
p50Ms = 122,
|
||||
p95Ms = 301,
|
||||
p99Ms = 410,
|
||||
meetsSla = true,
|
||||
slaTargetP95Ms = 500
|
||||
},
|
||||
supersedesDepth = new
|
||||
{
|
||||
maxDepth = 4,
|
||||
avgDepth = 1.6,
|
||||
distribution = new[]
|
||||
{
|
||||
new { depth = 1, count = 1180 },
|
||||
new { depth = 2, count = 242 },
|
||||
new { depth = 3, count = 44 },
|
||||
new { depth = 4, count = 6 }
|
||||
}
|
||||
},
|
||||
periodStart = now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
|
||||
periodEnd = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
recentViolations = BuildGuardViolations(now, page: 1, pageSize: 5).Items,
|
||||
ingestionFlow = BuildIngestionFlow(now)
|
||||
});
|
||||
})
|
||||
.WithName("AocCompatibility.GetComplianceDashboard");
|
||||
|
||||
group.MapGet("/compliance/violations", (
|
||||
[FromQuery] int? page,
|
||||
[FromQuery] int? pageSize,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var effectivePage = page is > 0 ? page.Value : 1;
|
||||
var effectivePageSize = pageSize is > 0 ? pageSize.Value : 20;
|
||||
var response = BuildGuardViolations(timeProvider.GetUtcNow(), effectivePage, effectivePageSize);
|
||||
return Results.Ok(response);
|
||||
})
|
||||
.WithName("AocCompatibility.GetViolations");
|
||||
|
||||
group.MapPost("/compliance/violations/{violationId}/retry", (string violationId) =>
|
||||
Results.Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = $"Retry scheduled for {violationId}."
|
||||
}))
|
||||
.WithName("AocCompatibility.RetryViolation");
|
||||
|
||||
group.MapGet("/ingestion/flow", ([FromServices] TimeProvider timeProvider) =>
|
||||
Results.Ok(BuildIngestionFlow(timeProvider.GetUtcNow())))
|
||||
.WithName("AocCompatibility.GetIngestionFlow");
|
||||
|
||||
group.MapPost("/provenance/validate", (
|
||||
AocProvenanceValidateRequest request,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var inputType = string.IsNullOrWhiteSpace(request.InputType) ? "finding_id" : request.InputType!;
|
||||
var inputValue = string.IsNullOrWhiteSpace(request.InputValue) ? "finding-001" : request.InputValue!;
|
||||
return Results.Ok(new
|
||||
{
|
||||
inputType,
|
||||
inputValue,
|
||||
steps = new[]
|
||||
{
|
||||
new AocProvenanceStep(
|
||||
"source",
|
||||
"Registry intake",
|
||||
now.AddMinutes(-40).ToString("O", CultureInfo.InvariantCulture),
|
||||
"sha256:1f7d98a2bf54c390",
|
||||
null,
|
||||
"valid",
|
||||
new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["source"] = "docker-hub",
|
||||
["artifact"] = "nginx:1.27.4"
|
||||
}),
|
||||
new AocProvenanceStep(
|
||||
"normalized",
|
||||
"Concelier normalization",
|
||||
now.AddMinutes(-33).ToString("O", CultureInfo.InvariantCulture),
|
||||
"sha256:4fdb5e6a31a80f0d",
|
||||
"sha256:1f7d98a2bf54c390",
|
||||
"valid",
|
||||
new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["pipeline"] = "concelier",
|
||||
["tenant"] = "demo-prod"
|
||||
}),
|
||||
new AocProvenanceStep(
|
||||
"finding",
|
||||
"Finding materialized",
|
||||
now.AddMinutes(-22).ToString("O", CultureInfo.InvariantCulture),
|
||||
"sha256:0a52b69d9f9c72c0",
|
||||
"sha256:4fdb5e6a31a80f0d",
|
||||
"valid",
|
||||
new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||
{
|
||||
["findingId"] = inputValue,
|
||||
["decision"] = "warn"
|
||||
})
|
||||
},
|
||||
isComplete = true,
|
||||
validationErrors = Array.Empty<string>(),
|
||||
validatedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
});
|
||||
})
|
||||
.WithName("AocCompatibility.ValidateProvenance");
|
||||
|
||||
group.MapPost("/compliance/reports", (
|
||||
AocComplianceReportRequest request,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return Results.Ok(new
|
||||
{
|
||||
reportId = $"aoc-report-{now:yyyyMMddHHmmss}",
|
||||
generatedAt = now.ToString("O", CultureInfo.InvariantCulture),
|
||||
period = new
|
||||
{
|
||||
start = request.StartDate ?? now.AddDays(-7).ToString("O", CultureInfo.InvariantCulture),
|
||||
end = request.EndDate ?? now.ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
guardViolationSummary = new
|
||||
{
|
||||
total = 23,
|
||||
bySource = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["docker-hub"] = 12,
|
||||
["github-packages"] = 11
|
||||
},
|
||||
byReason = new Dictionary<string, int>(StringComparer.Ordinal)
|
||||
{
|
||||
["schema_invalid"] = 9,
|
||||
["missing_required_fields"] = 8,
|
||||
["hash_mismatch"] = 6
|
||||
}
|
||||
},
|
||||
provenanceCompliance = new
|
||||
{
|
||||
percentage = 99.1,
|
||||
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["docker-hub"] = 99.5,
|
||||
["github-packages"] = 98.7
|
||||
}
|
||||
},
|
||||
deduplicationMetrics = new
|
||||
{
|
||||
rate = 12.4,
|
||||
bySource = new Dictionary<string, double>(StringComparer.Ordinal)
|
||||
{
|
||||
["docker-hub"] = 10.8,
|
||||
["github-packages"] = 14.1
|
||||
}
|
||||
},
|
||||
latencyMetrics = new
|
||||
{
|
||||
p50Ms = 122,
|
||||
p95Ms = 301,
|
||||
p99Ms = 410,
|
||||
bySource = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["docker-hub"] = new { p50 = 118, p95 = 292, p99 = 401 },
|
||||
["github-packages"] = new { p50 = 126, p95 = 312, p99 = 420 }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.WithName("AocCompatibility.GenerateComplianceReport");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static object[] BuildViolationSummaries(DateTimeOffset now) =>
|
||||
[
|
||||
new
|
||||
{
|
||||
code = "AOC-PROV-001",
|
||||
description = "Missing provenance attestation",
|
||||
count = 12,
|
||||
severity = "high",
|
||||
lastSeen = now.AddMinutes(-15).ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
new
|
||||
{
|
||||
code = "AOC-DIGEST-002",
|
||||
description = "Digest mismatch in manifest",
|
||||
count = 7,
|
||||
severity = "critical",
|
||||
lastSeen = now.AddMinutes(-42).ToString("O", CultureInfo.InvariantCulture)
|
||||
},
|
||||
new
|
||||
{
|
||||
code = "AOC-SCHEMA-003",
|
||||
description = "Schema validation failed",
|
||||
count = 4,
|
||||
severity = "medium",
|
||||
lastSeen = now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture)
|
||||
}
|
||||
];
|
||||
|
||||
private static object BuildIngestionFlow(DateTimeOffset now) => new
|
||||
{
|
||||
sources = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
sourceId = "docker-hub",
|
||||
sourceName = "Docker Hub",
|
||||
module = "concelier",
|
||||
throughputPerMinute = 5.2,
|
||||
latencyP50Ms = 112,
|
||||
latencyP95Ms = 284,
|
||||
latencyP99Ms = 365,
|
||||
errorRate = 0.12,
|
||||
backlogDepth = 2,
|
||||
lastIngestionAt = now.AddMinutes(-3).ToString("O", CultureInfo.InvariantCulture),
|
||||
status = "healthy"
|
||||
},
|
||||
new
|
||||
{
|
||||
sourceId = "github-packages",
|
||||
sourceName = "GitHub Packages",
|
||||
module = "excititor",
|
||||
throughputPerMinute = 3.7,
|
||||
latencyP50Ms = 133,
|
||||
latencyP95Ms = 318,
|
||||
latencyP99Ms = 411,
|
||||
errorRate = 0.24,
|
||||
backlogDepth = 1,
|
||||
lastIngestionAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
|
||||
status = "degraded"
|
||||
}
|
||||
},
|
||||
totalThroughput = 8.9,
|
||||
avgLatencyP95Ms = 301,
|
||||
overallErrorRate = 0.18,
|
||||
lastUpdatedAt = now.ToString("O", CultureInfo.InvariantCulture)
|
||||
};
|
||||
|
||||
private static AocGuardViolationResponse BuildGuardViolations(DateTimeOffset now, int page, int pageSize)
|
||||
{
|
||||
var all = new[]
|
||||
{
|
||||
new AocGuardViolation(
|
||||
"viol-001",
|
||||
now.AddMinutes(-16).ToString("O", CultureInfo.InvariantCulture),
|
||||
"docker-hub",
|
||||
"missing_required_fields",
|
||||
"Provenance digest missing from normalized advisory.",
|
||||
"{\"digest\":null}",
|
||||
"concelier",
|
||||
true),
|
||||
new AocGuardViolation(
|
||||
"viol-002",
|
||||
now.AddMinutes(-44).ToString("O", CultureInfo.InvariantCulture),
|
||||
"github-packages",
|
||||
"hash_mismatch",
|
||||
"Manifest digest did not match DSSE payload.",
|
||||
"{\"expected\":\"sha256:a\",\"actual\":\"sha256:b\"}",
|
||||
"excititor",
|
||||
true),
|
||||
new AocGuardViolation(
|
||||
"viol-003",
|
||||
now.AddHours(-2).ToString("O", CultureInfo.InvariantCulture),
|
||||
"docker-hub",
|
||||
"schema_invalid",
|
||||
"Document failed schema validation for SPDX 2.3.",
|
||||
"{\"schema\":\"spdx-2.3\"}",
|
||||
"concelier",
|
||||
false)
|
||||
};
|
||||
|
||||
var effectivePage = page > 0 ? page : 1;
|
||||
var effectivePageSize = pageSize > 0 ? pageSize : 20;
|
||||
var skip = (effectivePage - 1) * effectivePageSize;
|
||||
var items = all.Skip(skip).Take(effectivePageSize).ToArray();
|
||||
|
||||
return new AocGuardViolationResponse(
|
||||
items,
|
||||
all.Length,
|
||||
effectivePage,
|
||||
effectivePageSize,
|
||||
skip + items.Length < all.Length);
|
||||
}
|
||||
|
||||
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
|
||||
=> tenantId?.Trim()
|
||||
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
|
||||
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
|
||||
?? httpContext.User.Claims.FirstOrDefault(static claim =>
|
||||
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
|
||||
|
||||
private sealed record AocVerifyRequest(string? TenantId, string? Since, int? Limit);
|
||||
|
||||
private sealed record AocProvenanceValidateRequest(string? InputType, string? InputValue);
|
||||
|
||||
private sealed record AocComplianceReportRequest(
|
||||
string? StartDate,
|
||||
string? EndDate,
|
||||
IReadOnlyList<string>? Sources,
|
||||
string? Format,
|
||||
bool IncludeViolationDetails);
|
||||
|
||||
private sealed record AocGuardViolation(
|
||||
string Id,
|
||||
string Timestamp,
|
||||
string Source,
|
||||
string Reason,
|
||||
string Message,
|
||||
string PayloadSample,
|
||||
string Module,
|
||||
bool CanRetry);
|
||||
|
||||
private sealed record AocGuardViolationResponse(
|
||||
IReadOnlyList<AocGuardViolation> Items,
|
||||
int TotalCount,
|
||||
int Page,
|
||||
int PageSize,
|
||||
bool HasMore);
|
||||
|
||||
private sealed record AocProvenanceStep(
|
||||
string StepType,
|
||||
string Label,
|
||||
string Timestamp,
|
||||
string Hash,
|
||||
string? LinkedFromHash,
|
||||
string Status,
|
||||
IReadOnlyDictionary<string, object?> Details);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class ConsoleCompatibilityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapConsoleCompatibilityEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/console/status", (
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenant = ResolveTenant(httpContext, tenantId);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_required" });
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var backlog = Math.Abs(tenant.GetHashCode(StringComparison.Ordinal)) % 8 + 4;
|
||||
var activeRuns = (backlog % 3) + 1;
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
backlog,
|
||||
queueLagMs = 180 + (backlog * 35),
|
||||
activeRuns,
|
||||
pendingRuns = Math.Max(0, backlog - activeRuns),
|
||||
lastCompletedRunId = $"run::{tenant}::{now:yyyyMMdd}",
|
||||
lastCompletedAt = now.AddMinutes(-6).ToString("O", CultureInfo.InvariantCulture),
|
||||
healthy = true
|
||||
});
|
||||
})
|
||||
.WithTags("Console Compatibility")
|
||||
.WithName("ConsoleStatus.Get")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
app.MapGet("/api/console/runs/{runId}/first-signal", (
|
||||
HttpContext httpContext,
|
||||
[FromRoute] string runId,
|
||||
[FromQuery] string? tenantId,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenant = ResolveTenant(httpContext, tenantId);
|
||||
if (string.IsNullOrWhiteSpace(tenant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_required" });
|
||||
}
|
||||
|
||||
var signal = CreateCompatibilityFirstSignal(runId, tenant, timeProvider);
|
||||
var typedHeaders = httpContext.Request.GetTypedHeaders();
|
||||
if (typedHeaders.IfNoneMatch?.Any(tag => string.Equals(tag.Tag.Value, signal.SummaryEtag, StringComparison.Ordinal)) == true)
|
||||
{
|
||||
httpContext.Response.Headers.ETag = signal.SummaryEtag;
|
||||
httpContext.Response.Headers.Append("Cache-Status", "compatibility; hit");
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
httpContext.Response.Headers.ETag = signal.SummaryEtag;
|
||||
httpContext.Response.Headers.Append("Cache-Status", "compatibility; generated");
|
||||
return Results.Ok(signal);
|
||||
})
|
||||
.WithTags("Console Compatibility")
|
||||
.WithName("ConsoleStatus.FirstSignal")
|
||||
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.OpsHealth))
|
||||
.RequireTenant();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static string? ResolveTenant(HttpContext httpContext, string? tenantId)
|
||||
=> tenantId?.Trim()
|
||||
?? httpContext.Request.Headers["X-StellaOps-Tenant"].FirstOrDefault()
|
||||
?? httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault()
|
||||
?? httpContext.User.Claims.FirstOrDefault(static claim =>
|
||||
claim.Type is "stellaops:tenant" or "tenant_id")?.Value;
|
||||
|
||||
private static ConsoleCompatibilityFirstSignalResponse CreateCompatibilityFirstSignal(
|
||||
string runId,
|
||||
string tenant,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var completedAt = ResolveCompletedAt(runId, timeProvider.GetUtcNow());
|
||||
var summaryEtag = CreateSummaryEtag(tenant, runId, completedAt);
|
||||
|
||||
return new ConsoleCompatibilityFirstSignalResponse(
|
||||
runId,
|
||||
new ConsoleCompatibilityFirstSignal(
|
||||
Type: "completed",
|
||||
Stage: "console",
|
||||
Step: "snapshot",
|
||||
Message: $"Console captured the latest completed snapshot for {runId}.",
|
||||
At: completedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||
Artifact: new ConsoleCompatibilityFirstSignalArtifact("run")),
|
||||
summaryEtag);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ResolveCompletedAt(string runId, DateTimeOffset now)
|
||||
{
|
||||
var segments = runId.Split("::", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length > 0 &&
|
||||
DateOnly.TryParseExact(segments[^1], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var completedOn))
|
||||
{
|
||||
return completedOn.ToDateTime(TimeOnly.FromTimeSpan(TimeSpan.FromHours(12)), DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
return now.AddMinutes(-6);
|
||||
}
|
||||
|
||||
private static string CreateSummaryEtag(string tenant, string runId, DateTimeOffset completedAt)
|
||||
{
|
||||
var material = Encoding.UTF8.GetBytes($"{tenant}|{runId}|{completedAt:O}");
|
||||
var digest = Convert.ToHexStringLower(SHA256.HashData(material));
|
||||
return $"\"compat-first-signal-{digest[..16]}\"";
|
||||
}
|
||||
|
||||
private sealed record ConsoleCompatibilityFirstSignalResponse(
|
||||
string RunId,
|
||||
ConsoleCompatibilityFirstSignal FirstSignal,
|
||||
string SummaryEtag);
|
||||
|
||||
private sealed record ConsoleCompatibilityFirstSignal(
|
||||
string Type,
|
||||
string Stage,
|
||||
string Step,
|
||||
string Message,
|
||||
string At,
|
||||
ConsoleCompatibilityFirstSignalArtifact Artifact);
|
||||
|
||||
private sealed record ConsoleCompatibilityFirstSignalArtifact(string Kind);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Infrastructure.Postgres.Migrations;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
@@ -120,8 +121,8 @@ builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthRead, PlatformScopes.OpsHealth);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.HealthAdmin, PlatformScopes.OpsAdmin);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin);
|
||||
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaRead, PlatformScopes.QuotaRead, StellaOpsScopes.OrchQuota);
|
||||
options.AddStellaOpsAnyScopePolicy(PlatformPolicies.QuotaAdmin, PlatformScopes.QuotaAdmin, StellaOpsScopes.OrchQuota);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingRead, PlatformScopes.OnboardingRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.OnboardingWrite, PlatformScopes.OnboardingWrite);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.PreferencesRead, PlatformScopes.PreferencesRead);
|
||||
@@ -325,6 +326,8 @@ app.MapEvidenceReadModelEndpoints();
|
||||
app.MapIntegrationReadModelEndpoints();
|
||||
app.MapLegacyAliasEndpoints();
|
||||
app.MapPackAdapterEndpoints();
|
||||
app.MapConsoleCompatibilityEndpoints();
|
||||
app.MapAocCompatibilityEndpoints();
|
||||
app.MapAdministrationTrustSigningMutationEndpoints();
|
||||
app.MapFederationTelemetryEndpoints();
|
||||
app.MapSeedEndpoints();
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class CompatibilityEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public CompatibilityEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConsoleStatus_ReturnsDeterministicPayload()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
|
||||
var response = await client.GetAsync("/api/console/status", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.True(payload.TryGetProperty("healthy", out var healthy));
|
||||
Assert.True(healthy.GetBoolean());
|
||||
Assert.True(payload.GetProperty("backlog").GetInt32() > 0);
|
||||
Assert.False(string.IsNullOrWhiteSpace(payload.GetProperty("lastCompletedRunId").GetString()));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task ConsoleRunFirstSignal_ReturnsCompatibilitySnapshot_AndHonorsConditionalRequests()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
|
||||
var response = await client.GetAsync("/api/console/runs/run::demo-prod::20260309/first-signal", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("run::demo-prod::20260309", payload.GetProperty("runId").GetString());
|
||||
Assert.Equal("completed", payload.GetProperty("firstSignal").GetProperty("type").GetString());
|
||||
Assert.Equal("snapshot", payload.GetProperty("firstSignal").GetProperty("step").GetString());
|
||||
Assert.Equal("run", payload.GetProperty("firstSignal").GetProperty("artifact").GetProperty("kind").GetString());
|
||||
|
||||
var etag = response.Headers.ETag?.Tag;
|
||||
Assert.False(string.IsNullOrWhiteSpace(etag));
|
||||
Assert.Contains("compatibility", string.Join(", ", response.Headers.GetValues("Cache-Status")));
|
||||
|
||||
using var conditionalRequest = new HttpRequestMessage(HttpMethod.Get, "/api/console/runs/run::demo-prod::20260309/first-signal");
|
||||
conditionalRequest.Headers.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
conditionalRequest.Headers.TryAddWithoutValidation("If-None-Match", etag);
|
||||
|
||||
var notModified = await client.SendAsync(conditionalRequest, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task AocMetrics_RespectRequestedWindow()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/aoc/metrics?windowMinutes=60", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(12870, payload.GetProperty("totalCount").GetInt32());
|
||||
Assert.Equal(60, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
|
||||
Assert.True(payload.GetProperty("recentViolations").GetArrayLength() > 0);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task AocMetrics_DefaultWindowWhenCallerOmitsWindowMinutes()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/aoc/metrics", TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.Equal(1440, payload.GetProperty("timeWindow").GetProperty("durationMinutes").GetInt32());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Fact]
|
||||
public async Task AocComplianceEndpoints_ReturnDashboardAndRetryableViolations()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "demo-prod");
|
||||
|
||||
var dashboardResponse = await client.GetAsync("/api/v1/aoc/compliance/dashboard", TestContext.Current.CancellationToken);
|
||||
dashboardResponse.EnsureSuccessStatusCode();
|
||||
var dashboard = await dashboardResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.True(dashboard.TryGetProperty("metrics", out _));
|
||||
Assert.True(dashboard.GetProperty("recentViolations").GetArrayLength() > 0);
|
||||
|
||||
var retryResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/aoc/compliance/violations/viol-001/retry",
|
||||
new { },
|
||||
TestContext.Current.CancellationToken);
|
||||
retryResponse.EnsureSuccessStatusCode();
|
||||
var retryPayload = await retryResponse.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
|
||||
Assert.True(retryPayload.GetProperty("success").GetBoolean());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using Xunit;
|
||||
|
||||
@@ -124,4 +128,29 @@ public sealed class QuotaEndpointsTests : IClassFixture<PlatformWebApplicationFa
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("tenant_forbidden", body!["error"]?.GetValue<string>());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task QuotaPolicies_AcceptLegacyOrchQuotaScope()
|
||||
{
|
||||
var policyProvider = factory.Services.GetRequiredService<IAuthorizationPolicyProvider>();
|
||||
|
||||
var readPolicy = await policyProvider.GetPolicyAsync("platform.quota.read");
|
||||
var adminPolicy = await policyProvider.GetPolicyAsync("platform.quota.admin");
|
||||
|
||||
Assert.NotNull(readPolicy);
|
||||
Assert.NotNull(adminPolicy);
|
||||
|
||||
var readScopes = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>()).RequiredScopes;
|
||||
var readRequirement = Assert.Single(readPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
|
||||
var adminRequirement = Assert.Single(adminPolicy!.Requirements.OfType<StellaOpsScopeRequirement>());
|
||||
var adminScopes = adminRequirement.RequiredScopes;
|
||||
|
||||
Assert.Contains(StellaOpsScopes.OrchQuota, readScopes);
|
||||
Assert.Contains("quota.read", readScopes);
|
||||
Assert.Contains(StellaOpsScopes.OrchQuota, adminScopes);
|
||||
Assert.Contains("quota.admin", adminScopes);
|
||||
Assert.False(readRequirement.RequireAllScopes);
|
||||
Assert.False(adminRequirement.RequireAllScopes);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user