Complete release compatibility and host inventory sprints

Signed-off-by: master <>
This commit is contained in:
master
2026-03-31 23:53:45 +03:00
parent b6bf113b99
commit f96c6cb9ed
33 changed files with 2322 additions and 362 deletions

View File

@@ -50,7 +50,10 @@ public sealed record TopologyHostProjection(
string Status,
string AgentId,
int TargetCount,
DateTimeOffset? LastSeenAt);
DateTimeOffset? LastSeenAt,
string? ProbeStatus = null,
string? ProbeType = null,
DateTimeOffset? ProbeLastHeartbeat = null);
public sealed record TopologyAgentProjection(
string AgentId,

View File

@@ -24,12 +24,16 @@ public static class AssistantEndpoints
group.MapGet("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string route,
[FromQuery] string? locale,
[FromQuery] string? contexts,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var contextList = string.IsNullOrWhiteSpace(contexts)
@@ -46,11 +50,15 @@ public static class AssistantEndpoints
group.MapGet("/glossary", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? terms,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var termList = string.IsNullOrWhiteSpace(terms)
@@ -67,9 +75,13 @@ public static class AssistantEndpoints
group.MapGet("/user-state", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var (userId, tenantId) = ResolveUserContext(httpContext);
var state = await store.GetUserStateAsync(userId, tenantId, ct);
return state is not null ? Results.Ok(state) : Results.Ok(new AssistantUserStateDto(
@@ -80,10 +92,14 @@ public static class AssistantEndpoints
group.MapPut("/user-state", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
AssistantUserStateDto state,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var (userId, tenantId) = ResolveUserContext(httpContext);
await store.UpsertUserStateAsync(userId, tenantId, state, ct);
return Results.Ok();
@@ -96,11 +112,15 @@ public static class AssistantEndpoints
group.MapGet("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? tourKey,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var effectiveLocale = locale ?? "en-US";
var result = await store.GetToursAsync(effectiveLocale, tenantId, tourKey, ct);
@@ -117,10 +137,14 @@ public static class AssistantEndpoints
admin.MapPost("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertAssistantTipRequest request,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var actor = ResolveUserId(httpContext);
var id = await store.UpsertTipAsync(tenantId, request, actor, ct);
@@ -130,10 +154,15 @@ public static class AssistantEndpoints
.WithSummary("Create or update a tip");
admin.MapDelete("/tips/{tipId}", async Task<IResult>(
HttpContext httpContext,
string tipId,
PostgresAssistantStore store,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
await store.DeactivateTipAsync(tipId, ct);
return Results.Ok();
})
@@ -142,11 +171,15 @@ public static class AssistantEndpoints
admin.MapGet("/tips", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
[FromQuery] string? route,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var result = await store.ListAllTipsAsync(tenantId, locale ?? "en-US", route, ct);
return Results.Ok(result);
@@ -156,10 +189,14 @@ public static class AssistantEndpoints
admin.MapGet("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var result = await store.ListAllToursAsync(tenantId, locale ?? "en-US", ct);
return Results.Ok(result);
@@ -169,10 +206,14 @@ public static class AssistantEndpoints
admin.MapPost("/tours", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertTourRequest request,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var id = await store.UpsertTourAsync(tenantId, request, ct);
return Results.Ok(new { tourId = id });
@@ -183,10 +224,14 @@ public static class AssistantEndpoints
admin.MapGet("/tours/{tourKey}", async Task<IResult>(
string tourKey,
HttpContext httpContext,
PostgresAssistantStore store,
[FromQuery] string? locale,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var tour = await store.GetTourByKeyAsync(tenantId, tourKey, locale ?? "en-US", ct);
return tour is not null ? Results.Ok(tour) : Results.NotFound();
@@ -196,10 +241,14 @@ public static class AssistantEndpoints
admin.MapPost("/glossary", async Task<IResult>(
HttpContext httpContext,
PostgresAssistantStore store,
UpsertGlossaryTermRequest request,
CancellationToken ct) =>
{
if (!TryResolveStore(httpContext, out var store))
{
return AssistantStoreUnavailable();
}
var tenantId = ResolveTenantId(httpContext);
var id = await store.UpsertGlossaryTermAsync(tenantId, request, ct);
return Results.Ok(new { termId = id });
@@ -215,6 +264,20 @@ public static class AssistantEndpoints
?? ctx.User.FindFirst("stellaops:user_id")?.Value
?? "anonymous";
private static bool TryResolveStore(HttpContext context, out PostgresAssistantStore store)
{
store = context.RequestServices.GetService<PostgresAssistantStore>()!;
return store is not null;
}
private static IResult AssistantStoreUnavailable()
{
return Results.Problem(
detail: "Assistant persistence is unavailable because the Platform service is running without PostgreSQL.",
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "assistant_store_unavailable");
}
private static string ResolveTenantId(HttpContext ctx)
=> ctx.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";

View File

@@ -0,0 +1,456 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Platform.WebService.Constants;
using StellaOps.ReleaseOrchestrator.Environment.FreezeWindow;
using StellaOps.ReleaseOrchestrator.Environment.Models;
using StellaOps.ReleaseOrchestrator.Environment.Services;
using StellaOps.ReleaseOrchestrator.Environment.Target;
namespace StellaOps.Platform.WebService.Endpoints;
public static class ReleaseOrchestratorEnvironmentEndpoints
{
public static IEndpointRouteBuilder MapReleaseOrchestratorEnvironmentEndpoints(this IEndpointRouteBuilder app)
{
var environments = app.MapGroup("/api/v1/release-orchestrator/environments")
.WithTags("Release Orchestrator Environments")
.RequireAuthorization(PlatformPolicies.ReleaseControlRead)
.RequireTenant();
environments.MapGet(string.Empty, ListEnvironments)
.WithName("ListReleaseOrchestratorEnvironments")
.WithSummary("List release orchestrator environments");
environments.MapGet("/{id:guid}", GetEnvironment)
.WithName("GetReleaseOrchestratorEnvironment")
.WithSummary("Get a release orchestrator environment");
environments.MapPost(string.Empty, CreateEnvironment)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("CreateReleaseOrchestratorEnvironment")
.WithSummary("Create a release orchestrator environment");
environments.MapPut("/{id:guid}", UpdateEnvironment)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("UpdateReleaseOrchestratorEnvironment")
.WithSummary("Update a release orchestrator environment");
environments.MapDelete("/{id:guid}", DeleteEnvironment)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("DeleteReleaseOrchestratorEnvironment")
.WithSummary("Delete a release orchestrator environment");
environments.MapPut("/{id:guid}/settings", UpdateEnvironmentSettings)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("UpdateReleaseOrchestratorEnvironmentSettings")
.WithSummary("Update environment release settings");
environments.MapGet("/{id:guid}/targets", ListTargets)
.WithName("ListReleaseOrchestratorEnvironmentTargets")
.WithSummary("List environment targets");
environments.MapPost("/{id:guid}/targets", CreateTarget)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("CreateReleaseOrchestratorEnvironmentTarget")
.WithSummary("Create an environment target");
environments.MapPut("/{id:guid}/targets/{targetId:guid}", UpdateTarget)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("UpdateReleaseOrchestratorEnvironmentTarget")
.WithSummary("Update an environment target");
environments.MapDelete("/{id:guid}/targets/{targetId:guid}", DeleteTarget)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("DeleteReleaseOrchestratorEnvironmentTarget")
.WithSummary("Delete an environment target");
environments.MapPost("/{id:guid}/targets/{targetId:guid}/health-check", CheckTargetHealth)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("HealthCheckReleaseOrchestratorEnvironmentTarget")
.WithSummary("Run a target health check");
environments.MapGet("/{id:guid}/freeze-windows", ListFreezeWindows)
.WithName("ListReleaseOrchestratorFreezeWindows")
.WithSummary("List environment freeze windows");
environments.MapPost("/{id:guid}/freeze-windows", CreateFreezeWindow)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("CreateReleaseOrchestratorFreezeWindow")
.WithSummary("Create an environment freeze window");
environments.MapPut("/{id:guid}/freeze-windows/{freezeWindowId:guid}", UpdateFreezeWindow)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("UpdateReleaseOrchestratorFreezeWindow")
.WithSummary("Update an environment freeze window");
environments.MapDelete("/{id:guid}/freeze-windows/{freezeWindowId:guid}", DeleteFreezeWindow)
.RequireAuthorization(PlatformPolicies.ReleaseControlOperate)
.WithName("DeleteReleaseOrchestratorFreezeWindow")
.WithSummary("Delete an environment freeze window");
return app;
}
private static async Task<IResult> ListEnvironments(
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
var items = await environmentService.ListOrderedAsync(cancellationToken).ConfigureAwait(false);
return Results.Ok(items);
}
private static async Task<IResult> GetEnvironment(
Guid id,
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
var environment = await environmentService.GetAsync(id, cancellationToken).ConfigureAwait(false);
return environment is not null
? Results.Ok(environment)
: Results.NotFound(new { error = "environment_not_found", id });
}
private static async Task<IResult> CreateEnvironment(
CreateEnvironmentRequest request,
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
try
{
var created = await environmentService.CreateAsync(request, cancellationToken).ConfigureAwait(false);
return Results.Created($"/api/v1/release-orchestrator/environments/{created.Id:D}", created);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = "environment_validation_failed", details = ex.Errors });
}
}
private static async Task<IResult> UpdateEnvironment(
Guid id,
UpdateEnvironmentRequest request,
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
try
{
var updated = await environmentService.UpdateAsync(id, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(updated);
}
catch (EnvironmentNotFoundException)
{
return Results.NotFound(new { error = "environment_not_found", id });
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = "environment_validation_failed", details = ex.Errors });
}
}
private static Task<IResult> UpdateEnvironmentSettings(
Guid id,
UpdateEnvironmentSettingsRequest request,
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
return UpdateEnvironment(
id,
new UpdateEnvironmentRequest(
RequiredApprovals: request.RequiredApprovals,
RequireSeparationOfDuties: request.RequireSeparationOfDuties,
AutoPromoteFrom: request.AutoPromoteFrom,
DeploymentTimeoutSeconds: request.DeploymentTimeoutSeconds),
environmentService,
cancellationToken);
}
private static async Task<IResult> DeleteEnvironment(
Guid id,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
var targets = await targetRegistry.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
if (targets.Count > 0)
{
return Results.Conflict(new { error = "environment_has_targets", id, targetCount = targets.Count });
}
try
{
await environmentService.DeleteAsync(id, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
catch (EnvironmentNotFoundException)
{
return Results.NotFound(new { error = "environment_not_found", id });
}
catch (InvalidOperationException ex)
{
return Results.Conflict(new { error = "environment_delete_blocked", message = ex.Message });
}
}
private static async Task<IResult> ListTargets(
Guid id,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var targets = await targetRegistry.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
return Results.Ok(targets);
}
private static async Task<IResult> CreateTarget(
Guid id,
CreateTargetRequest request,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
try
{
var created = await targetRegistry.RegisterAsync(
new RegisterTargetRequest(
id,
request.Name,
request.DisplayName,
request.Type,
request.ConnectionConfig,
request.AgentId),
cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/v1/release-orchestrator/environments/{id:D}/targets/{created.Id:D}",
created);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = "target_validation_failed", details = ex.Errors });
}
}
private static async Task<IResult> UpdateTarget(
Guid id,
Guid targetId,
UpdateTargetRequest request,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
if (target is null || target.EnvironmentId != id)
{
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
}
try
{
var updated = await targetRegistry.UpdateAsync(targetId, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(updated);
}
catch (ValidationException ex)
{
return Results.BadRequest(new { error = "target_validation_failed", details = ex.Errors });
}
}
private static async Task<IResult> DeleteTarget(
Guid id,
Guid targetId,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
if (target is null || target.EnvironmentId != id)
{
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
}
try
{
await targetRegistry.UnregisterAsync(targetId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
catch (InvalidOperationException ex)
{
return Results.Conflict(new { error = "target_delete_blocked", message = ex.Message });
}
}
private static async Task<IResult> CheckTargetHealth(
Guid id,
Guid targetId,
IEnvironmentService environmentService,
ITargetRegistry targetRegistry,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var target = await targetRegistry.GetAsync(targetId, cancellationToken).ConfigureAwait(false);
if (target is null || target.EnvironmentId != id)
{
return Results.NotFound(new { error = "target_not_found", environmentId = id, targetId });
}
var result = await targetRegistry.TestConnectionAsync(targetId, cancellationToken).ConfigureAwait(false);
return Results.Ok(result);
}
private static async Task<IResult> ListFreezeWindows(
Guid id,
IEnvironmentService environmentService,
IFreezeWindowService freezeWindowService,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var windows = await freezeWindowService.ListByEnvironmentAsync(id, cancellationToken).ConfigureAwait(false);
return Results.Ok(windows);
}
private static async Task<IResult> CreateFreezeWindow(
Guid id,
CreateFreezeWindowBody request,
IEnvironmentService environmentService,
IFreezeWindowService freezeWindowService,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
try
{
var created = await freezeWindowService.CreateAsync(
new CreateFreezeWindowRequest(
id,
request.Name,
request.StartAt,
request.EndAt,
request.Reason,
request.IsRecurring,
request.RecurrenceRule),
cancellationToken).ConfigureAwait(false);
return Results.Created(
$"/api/v1/release-orchestrator/environments/{id:D}/freeze-windows/{created.Id:D}",
created);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = "freeze_window_validation_failed", message = ex.Message });
}
}
private static async Task<IResult> UpdateFreezeWindow(
Guid id,
Guid freezeWindowId,
UpdateFreezeWindowRequest request,
IEnvironmentService environmentService,
IFreezeWindowService freezeWindowService,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var window = await freezeWindowService.GetAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
if (window is null || window.EnvironmentId != id)
{
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
}
try
{
var updated = await freezeWindowService.UpdateAsync(freezeWindowId, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(updated);
}
catch (FreezeWindowNotFoundException)
{
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
}
}
private static async Task<IResult> DeleteFreezeWindow(
Guid id,
Guid freezeWindowId,
IEnvironmentService environmentService,
IFreezeWindowService freezeWindowService,
CancellationToken cancellationToken)
{
if (!await EnvironmentExistsAsync(id, environmentService, cancellationToken).ConfigureAwait(false))
{
return Results.NotFound(new { error = "environment_not_found", id });
}
var window = await freezeWindowService.GetAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
if (window is null || window.EnvironmentId != id)
{
return Results.NotFound(new { error = "freeze_window_not_found", environmentId = id, freezeWindowId });
}
await freezeWindowService.DeleteAsync(freezeWindowId, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static async Task<bool> EnvironmentExistsAsync(
Guid environmentId,
IEnvironmentService environmentService,
CancellationToken cancellationToken)
{
return await environmentService.GetAsync(environmentId, cancellationToken).ConfigureAwait(false) is not null;
}
public sealed record CreateTargetRequest(
string Name,
string DisplayName,
TargetType Type,
TargetConnectionConfig ConnectionConfig,
Guid? AgentId = null);
public sealed record UpdateEnvironmentSettingsRequest(
int? RequiredApprovals = null,
bool? RequireSeparationOfDuties = null,
Guid? AutoPromoteFrom = null,
int? DeploymentTimeoutSeconds = null);
public sealed record CreateFreezeWindowBody(
string Name,
DateTimeOffset StartAt,
DateTimeOffset EndAt,
string? Reason = null,
bool IsRecurring = false,
string? RecurrenceRule = null);
}

View File

@@ -52,6 +52,7 @@ builder.Services.AddOptions<PlatformServiceOptions>()
builder.Services.AddSingleton<IPostConfigureOptions<PlatformServiceOptions>, StellaOpsEnvVarPostConfigure>();
builder.Services.AddStellaOpsTenantServices();
builder.Services.AddHttpContextAccessor();
builder.Services.AddStellaOpsCors(builder.Environment, builder.Configuration);
builder.Services.AddRouting(options => options.LowercaseUrls = true);
builder.Services.AddEndpointsApiExplorer();
@@ -162,6 +163,7 @@ builder.Services.AddAuthorization(options =>
});
builder.Services.AddSingleton<PlatformRequestContextResolver>();
builder.Services.AddSingleton<ReleaseOrchestratorCompatibilityIdentityAccessor>();
builder.Services.AddSingleton<PlatformCache>();
builder.Services.AddSingleton<PlatformAggregationMetrics>();
builder.Services.AddSingleton<LegacyAliasTelemetry>();
@@ -206,6 +208,44 @@ builder.Services.AddHttpClient("HarborFixture", client =>
builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
builder.Services.AddSingleton(sp => new StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore(
() => sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId()));
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(sp =>
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.InMemoryEnvironmentStore>());
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Services.IEnvironmentService>(sp =>
new StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService(
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Services.EnvironmentService>(),
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId,
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetActorId));
builder.Services.AddSingleton(sp => new StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore(
() => sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId()));
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(sp =>
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.InMemoryTargetStore>());
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester,
StellaOps.ReleaseOrchestrator.Environment.Target.NoOpTargetConnectionTester>();
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetRegistry>(sp =>
new StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry(
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetStore>(),
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Store.IEnvironmentStore>(),
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.Target.ITargetConnectionTester>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.Target.TargetRegistry>(),
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetTenantId));
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.InMemoryFreezeWindowStore>();
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowStore>(sp =>
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.InMemoryFreezeWindowStore>());
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowService>(sp =>
new StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.FreezeWindowService(
sp.GetRequiredService<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.IFreezeWindowStore>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger<StellaOps.ReleaseOrchestrator.Environment.FreezeWindow.FreezeWindowService>(),
sp.GetRequiredService<ReleaseOrchestratorCompatibilityIdentityAccessor>().GetActorId));
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Inventory.IRemoteCommandExecutor,
StellaOps.ReleaseOrchestrator.Environment.Inventory.NoOpRemoteCommandExecutor>();
builder.Services.AddSingleton<StellaOps.ReleaseOrchestrator.Environment.Inventory.IInventoryCollector,
StellaOps.ReleaseOrchestrator.Environment.Inventory.AgentInventoryCollector>();
builder.Services.AddSingleton<TopologyReadModelService>();
builder.Services.AddSingleton<StellaOps.ElkSharp.IElkLayoutEngine, StellaOps.ElkSharp.ElkSharpLayeredLayoutEngine>();
builder.Services.AddSingleton<TopologyLayoutService>();
@@ -350,6 +390,7 @@ app.MapScoreEndpoints();
app.MapFunctionMapEndpoints();
app.MapPolicyInteropEndpoints();
app.MapReleaseControlEndpoints();
app.MapReleaseOrchestratorEnvironmentEndpoints();
app.MapReleaseReadModelEndpoints();
app.MapTopologyReadModelEndpoints();
app.MapSecurityReadModelEndpoints();

View File

@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Http;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Platform.WebService.Services;
/// <summary>
/// Provides deterministic GUID identities for compatibility services that
/// store tenant and actor keys as GUID values.
/// </summary>
public sealed class ReleaseOrchestratorCompatibilityIdentityAccessor
{
private const string DefaultTenant = "_system";
private const string DefaultActor = "anonymous";
private readonly IHttpContextAccessor httpContextAccessor;
private readonly PlatformRequestContextResolver requestContextResolver;
public ReleaseOrchestratorCompatibilityIdentityAccessor(
IHttpContextAccessor httpContextAccessor,
PlatformRequestContextResolver requestContextResolver)
{
this.httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
this.requestContextResolver = requestContextResolver ?? throw new ArgumentNullException(nameof(requestContextResolver));
}
public Guid GetTenantId()
{
var key = TryResolveRequestContext(out var context)
? context!.TenantId
: DefaultTenant;
return CreateDeterministicGuid($"tenant:{key}");
}
public Guid GetActorId()
{
var key = TryResolveRequestContext(out var context)
? context!.ActorId
: DefaultActor;
return CreateDeterministicGuid($"actor:{key}");
}
private bool TryResolveRequestContext(out PlatformRequestContext? requestContext)
{
requestContext = null;
var httpContext = httpContextAccessor.HttpContext;
return httpContext is not null
&& requestContextResolver.TryResolve(httpContext, out requestContext, out _);
}
internal static Guid CreateDeterministicGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
Span<byte> bytes = stackalloc byte[16];
hash.AsSpan(0, 16).CopyTo(bytes);
return new Guid(bytes);
}
}

View File

@@ -358,9 +358,10 @@ public sealed class TopologyReadModelService
.GroupBy(target => target.HostId, StringComparer.Ordinal)
.Select(group =>
{
var first = group
var orderedTargets = group
.OrderBy(target => target.TargetId, StringComparer.Ordinal)
.First();
.ToArray();
var first = orderedTargets[0];
var hostStatus = ResolveHostStatus(group.Select(target => target.HealthStatus));
var lastSeen = MaxTimestamp(group.Select(target => target.LastSyncAt));
@@ -372,7 +373,7 @@ public sealed class TopologyReadModelService
RuntimeType: first.TargetType,
Status: hostStatus,
AgentId: first.AgentId,
TargetCount: group.Count(),
TargetCount: orderedTargets.Length,
LastSeenAt: lastSeen,
ProbeStatus: "not_installed",
ProbeType: null,

View File

@@ -25,6 +25,7 @@
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Router.AspNet\StellaOps.Router.AspNet.csproj" />
<ProjectReference Include="..\..\Router\__Libraries\StellaOps.Messaging\StellaOps.Messaging.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.EvidenceThread\StellaOps.ReleaseOrchestrator.EvidenceThread.csproj" />
<ProjectReference Include="..\..\ReleaseOrchestrator\__Libraries\StellaOps.ReleaseOrchestrator.Environment\StellaOps.ReleaseOrchestrator.Environment.csproj" />
<ProjectReference Include="..\StellaOps.Platform.Analytics\StellaOps.Platform.Analytics.csproj" />
<ProjectReference Include="..\..\Scanner\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\Signals\StellaOps.Signals\StellaOps.Signals.csproj" />

View File

@@ -16,8 +16,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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. |
| SPRINT_20260323_001-TASK-004 | DONE | Sprint `docs/implplan/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `/api/v1/release-orchestrator/environments/*` compatibility endpoints for environment, target, and freeze-window CRUD using deterministic in-memory Release Orchestrator services. |
| SPRINT_20260331_002-TASK-003 | DONE | Sprint `docs/implplan/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: topology host projections now expose projection-derived `ProbeStatus`, `ProbeType`, and `ProbeLastHeartbeat` fields for Console host inventory views. |
| SPRINT_20260323_001-TASK-004 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `/api/v1/release-orchestrator/environments/*` compatibility endpoints for environment, target, and freeze-window CRUD using deterministic in-memory Release Orchestrator services. |
| SPRINT_20260331_002-TASK-003 | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: topology host projections now expose projection-derived `ProbeStatus`, `ProbeType`, and `ProbeLastHeartbeat` fields for Console host inventory views. |
| 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`. |

View File

@@ -0,0 +1,174 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Platform.WebService.Endpoints;
using StellaOps.ReleaseOrchestrator.Environment.FreezeWindow;
using StellaOps.ReleaseOrchestrator.Environment.Inventory;
using StellaOps.ReleaseOrchestrator.Environment.Models;
using StellaOps.ReleaseOrchestrator.Environment.Services;
using StellaOps.ReleaseOrchestrator.Environment.Target;
using StellaOps.TestKit;
using System.Net;
using System.Net.Http.Json;
using RoEnvironment = StellaOps.ReleaseOrchestrator.Environment.Models.Environment;
using RoFreezeWindow = StellaOps.ReleaseOrchestrator.Environment.Models.FreezeWindow;
using RoTarget = StellaOps.ReleaseOrchestrator.Environment.Models.Target;
namespace StellaOps.Platform.WebService.Tests;
public sealed class ReleaseOrchestratorEnvironmentEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
{
private readonly PlatformWebApplicationFactory factory;
public ReleaseOrchestratorEnvironmentEndpointsTests(PlatformWebApplicationFactory factory)
{
this.factory = factory;
}
[Trait("Category", TestCategories.Integration)]
[Fact]
public async Task EnvironmentLifecycle_CreateTargetFreezeWindowAndDelete_Works()
{
using var client = CreateTenantClient("tenant-env-lifecycle");
var createEnvironmentResponse = await client.PostAsJsonAsync(
"/api/v1/release-orchestrator/environments",
new CreateEnvironmentRequest(
Name: "prod-eu",
DisplayName: "Production EU",
Description: "Primary production environment",
OrderIndex: 2,
IsProduction: true,
RequiredApprovals: 2,
RequireSeparationOfDuties: true,
AutoPromoteFrom: null,
DeploymentTimeoutSeconds: 900),
TestContext.Current.CancellationToken);
createEnvironmentResponse.EnsureSuccessStatusCode();
var environment = await createEnvironmentResponse.Content.ReadFromJsonAsync<RoEnvironment>(
TestContext.Current.CancellationToken);
Assert.NotNull(environment);
var list = await client.GetFromJsonAsync<List<RoEnvironment>>(
"/api/v1/release-orchestrator/environments",
TestContext.Current.CancellationToken);
Assert.NotNull(list);
Assert.Contains(list!, item => item.Id == environment!.Id);
var updateSettingsResponse = await client.PutAsJsonAsync(
$"/api/v1/release-orchestrator/environments/{environment!.Id:D}/settings",
new ReleaseOrchestratorEnvironmentEndpoints.UpdateEnvironmentSettingsRequest(
RequiredApprovals: 3,
RequireSeparationOfDuties: true,
DeploymentTimeoutSeconds: 1200),
TestContext.Current.CancellationToken);
updateSettingsResponse.EnsureSuccessStatusCode();
var updatedEnvironment = await updateSettingsResponse.Content.ReadFromJsonAsync<RoEnvironment>(
TestContext.Current.CancellationToken);
Assert.NotNull(updatedEnvironment);
Assert.Equal(3, updatedEnvironment!.RequiredApprovals);
Assert.Equal(1200, updatedEnvironment.DeploymentTimeoutSeconds);
var createTargetResponse = await client.PostAsJsonAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets",
new ReleaseOrchestratorEnvironmentEndpoints.CreateTargetRequest(
Name: "prod-eu-ssh-01",
DisplayName: "Prod EU SSH 01",
Type: TargetType.SshHost,
ConnectionConfig: new SshHostConfig
{
Host = "ssh.prod-eu.internal",
Username = "deploy",
PrivateKeySecretRef = "secret://ssh/prod-eu",
KnownHostsPolicy = KnownHostsPolicy.Prompt,
}),
TestContext.Current.CancellationToken);
createTargetResponse.EnsureSuccessStatusCode();
var target = await createTargetResponse.Content.ReadFromJsonAsync<RoTarget>(
TestContext.Current.CancellationToken);
Assert.NotNull(target);
Assert.Equal(TargetType.SshHost, target!.Type);
var healthCheckResponse = await client.PostAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets/{target.Id:D}/health-check",
content: null,
TestContext.Current.CancellationToken);
healthCheckResponse.EnsureSuccessStatusCode();
var healthCheck = await healthCheckResponse.Content.ReadFromJsonAsync<ConnectionTestResult>(
TestContext.Current.CancellationToken);
Assert.NotNull(healthCheck);
Assert.True(healthCheck!.Success);
var targets = await client.GetFromJsonAsync<List<RoTarget>>(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets",
TestContext.Current.CancellationToken);
Assert.NotNull(targets);
Assert.Contains(targets!, item => item.Id == target.Id);
var createFreezeWindowResponse = await client.PostAsJsonAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows",
new ReleaseOrchestratorEnvironmentEndpoints.CreateFreezeWindowBody(
Name: "Weekend Freeze",
StartAt: new DateTimeOffset(2026, 4, 4, 0, 0, 0, TimeSpan.Zero),
EndAt: new DateTimeOffset(2026, 4, 6, 0, 0, 0, TimeSpan.Zero),
Reason: "Weekend release freeze"),
TestContext.Current.CancellationToken);
createFreezeWindowResponse.EnsureSuccessStatusCode();
var freezeWindow = await createFreezeWindowResponse.Content.ReadFromJsonAsync<RoFreezeWindow>(
TestContext.Current.CancellationToken);
Assert.NotNull(freezeWindow);
var freezeWindows = await client.GetFromJsonAsync<List<RoFreezeWindow>>(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows",
TestContext.Current.CancellationToken);
Assert.NotNull(freezeWindows);
Assert.Contains(freezeWindows!, item => item.Id == freezeWindow!.Id);
var updateFreezeWindowResponse = await client.PutAsJsonAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows/{freezeWindow!.Id:D}",
new UpdateFreezeWindowRequest(
Name: "Weekend Freeze Updated",
Reason: "Extended validation window"),
TestContext.Current.CancellationToken);
updateFreezeWindowResponse.EnsureSuccessStatusCode();
var updatedFreezeWindow = await updateFreezeWindowResponse.Content.ReadFromJsonAsync<RoFreezeWindow>(
TestContext.Current.CancellationToken);
Assert.NotNull(updatedFreezeWindow);
Assert.Equal("Weekend Freeze Updated", updatedFreezeWindow!.Name);
var deleteFreezeWindowResponse = await client.DeleteAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/freeze-windows/{freezeWindow.Id:D}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleteFreezeWindowResponse.StatusCode);
var deleteTargetResponse = await client.DeleteAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}/targets/{target.Id:D}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleteTargetResponse.StatusCode);
var deleteEnvironmentResponse = await client.DeleteAsync(
$"/api/v1/release-orchestrator/environments/{environment.Id:D}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleteEnvironmentResponse.StatusCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CompatibilityServices_RegisterAgentInventoryCollector()
{
var collector = factory.Services.GetRequiredService<IInventoryCollector>();
Assert.IsType<AgentInventoryCollector>(collector);
}
private HttpClient CreateTenantClient(string tenantId)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "platform-compat-test");
return client;
}
}

View File

@@ -14,6 +14,8 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| 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. |
| SPRINT_20260323_001-TASK-004-T | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md`: added `ReleaseOrchestratorEnvironmentEndpointsTests` for environment, target, freeze-window, and compatibility DI wiring. |
| SPRINT_20260331_002-TASK-003-T | DONE | Sprint `docs-archived/implplan/2026-03-31-completed-sprints/SPRINT_20260331_002_BE_host_infrastructure_and_inventory.md`: extended `TopologyReadModelEndpointsTests` to verify host probe status/type/heartbeat projection fields. |
| 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). |

View File

@@ -73,7 +73,7 @@ public sealed class TopologyReadModelEndpointsTests : IClassFixture<PlatformWebA
Assert.Equal("not_installed", item.ProbeStatus);
Assert.Null(item.ProbeType);
Assert.Null(item.ProbeLastHeartbeat);
});
});
var agentsFirst = await client.GetFromJsonAsync<PlatformListResponse<TopologyAgentProjection>>(
"/api/v2/topology/agents?limit=20&offset=0",