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

@@ -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);
}