Complete release compatibility and host inventory sprints
Signed-off-by: master <>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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). |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user