stela ops usage fixes roles propagation and timoeut, one account to support multi tenants, migrations consolidation, search to support documentation, doctor and open api vector db search

This commit is contained in:
master
2026-02-22 19:27:54 +02:00
parent a29f438f53
commit bd8fee6ed8
373 changed files with 832097 additions and 3369 deletions

View File

@@ -0,0 +1,374 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
using StellaOps.Platform.Database;
using StellaOps.Platform.WebService.Constants;
using StellaOps.Platform.WebService.Services;
namespace StellaOps.Platform.WebService.Endpoints;
/// <summary>
/// Administrative migration endpoints used by CLI and UI orchestration paths.
/// </summary>
public static class MigrationAdminEndpoints
{
public static IEndpointRouteBuilder MapMigrationAdminEndpoints(this IEndpointRouteBuilder app)
{
var migrations = app.MapGroup("/api/v1/admin/migrations")
.WithTags("Admin - Migrations")
.RequireAuthorization(PlatformPolicies.SetupAdmin);
migrations.MapGet("/modules", () =>
{
var modules = MigrationModuleRegistry.ModuleNames
.OrderBy(static name => name, StringComparer.Ordinal)
.ToArray();
return Results.Ok(new MigrationModulesResponse { Modules = modules });
})
.WithName("GetMigrationModules")
.Produces<MigrationModulesResponse>(StatusCodes.Status200OK);
migrations.MapGet("/status", async Task<IResult> (
string? module,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Status");
if (!TryResolveModules(module, out var modules, out var failure))
{
return failure!;
}
try
{
var results = new List<MigrationStatusModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var status = await migrationService.GetStatusAsync(moduleInfo, ct).ConfigureAwait(false);
results.Add(new MigrationStatusModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
AppliedCount = status.AppliedCount,
PendingStartupCount = status.PendingStartupCount,
PendingReleaseCount = status.PendingReleaseCount,
LastAppliedMigration = status.LastAppliedMigration,
LastAppliedAt = status.LastAppliedAt,
PendingMigrations = status.PendingMigrations
.OrderBy(static pending => pending.Name, StringComparer.Ordinal)
.Select(static pending => new PendingMigrationResult
{
Name = pending.Name,
Category = pending.Category.ToString().ToLowerInvariant()
})
.ToArray(),
ChecksumErrors = status.ChecksumErrors
.OrderBy(static error => error, StringComparer.Ordinal)
.ToArray(),
IsUpToDate = status.IsUpToDate,
HasBlockingIssues = status.HasBlockingIssues
});
}
return Results.Ok(new MigrationStatusResponse { Modules = results });
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration status failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration status failed",
detail: "Unexpected server error while reading migration status.");
}
})
.WithName("GetMigrationStatus")
.Produces<MigrationStatusResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
migrations.MapGet("/verify", async Task<IResult> (
string? module,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Verify");
if (!TryResolveModules(module, out var modules, out var failure))
{
return failure!;
}
try
{
var results = new List<MigrationVerifyModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var errors = await migrationService.VerifyAsync(moduleInfo, ct).ConfigureAwait(false);
var orderedErrors = errors.OrderBy(static error => error, StringComparer.Ordinal).ToArray();
results.Add(new MigrationVerifyModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
Success = orderedErrors.Length == 0,
Errors = orderedErrors
});
}
return Results.Ok(new MigrationVerifyResponse { Modules = results });
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration verify failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration verify failed",
detail: "Unexpected server error while verifying migrations.");
}
})
.WithName("VerifyMigrations")
.Produces<MigrationVerifyResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
migrations.MapPost("/run", async Task<IResult> (
MigrationRunRequest? request,
PlatformMigrationAdminService migrationService,
ILoggerFactory loggerFactory,
CancellationToken ct) =>
{
var logger = loggerFactory.CreateLogger("MigrationAdminEndpoints.Run");
request ??= new MigrationRunRequest();
if (!TryResolveModules(request.Module, out var modules, out var moduleFailure))
{
return moduleFailure!;
}
if (!TryParseCategory(request.Category, out var category))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid category",
Detail = "Category must be one of: startup, release, seed, data.",
Status = StatusCodes.Status400BadRequest
});
}
if (category == MigrationCategory.Release && !request.DryRun && !request.Force)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Release migration approval required",
Detail = "Release migrations require dry-run preview or explicit force approval.",
Status = StatusCodes.Status400BadRequest
});
}
if (request.TimeoutSeconds is <= 0)
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid timeout",
Detail = "TimeoutSeconds must be greater than zero when provided.",
Status = StatusCodes.Status400BadRequest
});
}
try
{
var results = new List<MigrationRunModuleResult>(modules.Count);
foreach (var moduleInfo in modules)
{
var result = await migrationService
.RunAsync(moduleInfo, category, request.DryRun, request.TimeoutSeconds, ct)
.ConfigureAwait(false);
results.Add(new MigrationRunModuleResult
{
Module = moduleInfo.Name,
Schema = moduleInfo.SchemaName,
Success = result.Success,
AppliedCount = result.AppliedCount,
SkippedCount = result.SkippedCount,
FilteredCount = result.FilteredCount,
DurationMs = result.DurationMs,
Error = result.ErrorMessage,
ChecksumErrors = result.ChecksumErrors
.OrderBy(static error => error, StringComparer.Ordinal)
.ToArray()
});
}
return Results.Ok(new MigrationRunResponse
{
DryRun = request.DryRun,
Category = category?.ToString().ToLowerInvariant(),
Success = results.All(static result => result.Success),
Modules = results
});
}
catch (InvalidOperationException ex)
{
return Results.Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Database connection unavailable",
detail: ex.Message);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected migration run failure");
return Results.Problem(
statusCode: StatusCodes.Status500InternalServerError,
title: "Migration run failed",
detail: "Unexpected server error while running migrations.");
}
})
.WithName("RunMigrations")
.Produces<MigrationRunResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status503ServiceUnavailable)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
return app;
}
private static bool TryResolveModules(
string? moduleFilter,
out List<MigrationModuleInfo> modules,
out IResult? failure)
{
modules = MigrationModuleRegistry
.GetModules(moduleFilter)
.OrderBy(static module => module.Name, StringComparer.Ordinal)
.ToList();
if (modules.Count > 0)
{
failure = null;
return true;
}
var available = string.Join(", ", MigrationModuleRegistry.ModuleNames.OrderBy(static name => name, StringComparer.Ordinal));
failure = Results.BadRequest(new ProblemDetails
{
Title = "Invalid module filter",
Detail = $"No modules matched '{moduleFilter}'. Available modules: {available}.",
Status = StatusCodes.Status400BadRequest
});
return false;
}
private static bool TryParseCategory(string? value, out MigrationCategory? category)
{
if (string.IsNullOrWhiteSpace(value))
{
category = null;
return true;
}
category = value.Trim().ToLowerInvariant() switch
{
"startup" => MigrationCategory.Startup,
"release" => MigrationCategory.Release,
"seed" => MigrationCategory.Seed,
"data" => MigrationCategory.Data,
_ => null
};
return category is not null;
}
public sealed class MigrationModulesResponse
{
public IReadOnlyList<string> Modules { get; init; } = [];
}
public sealed class MigrationStatusResponse
{
public IReadOnlyList<MigrationStatusModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationStatusModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public int AppliedCount { get; init; }
public int PendingStartupCount { get; init; }
public int PendingReleaseCount { get; init; }
public string? LastAppliedMigration { get; init; }
public DateTimeOffset? LastAppliedAt { get; init; }
public IReadOnlyList<PendingMigrationResult> PendingMigrations { get; init; } = [];
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
public bool IsUpToDate { get; init; }
public bool HasBlockingIssues { get; init; }
}
public sealed class PendingMigrationResult
{
public string Name { get; init; } = string.Empty;
public string Category { get; init; } = string.Empty;
}
public sealed class MigrationVerifyResponse
{
public IReadOnlyList<MigrationVerifyModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationVerifyModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public bool Success { get; init; }
public IReadOnlyList<string> Errors { get; init; } = [];
}
public sealed class MigrationRunRequest
{
public string Module { get; init; } = "all";
public string? Category { get; init; }
public bool DryRun { get; init; }
public bool Force { get; init; }
public int? TimeoutSeconds { get; init; }
}
public sealed class MigrationRunResponse
{
public bool DryRun { get; init; }
public string? Category { get; init; }
public bool Success { get; init; }
public IReadOnlyList<MigrationRunModuleResult> Modules { get; init; } = [];
}
public sealed class MigrationRunModuleResult
{
public string Module { get; init; } = string.Empty;
public string Schema { get; init; } = string.Empty;
public bool Success { get; init; }
public int AppliedCount { get; init; }
public int SkippedCount { get; init; }
public int FilteredCount { get; init; }
public long DurationMs { get; init; }
public string? Error { get; init; }
public IReadOnlyList<string> ChecksumErrors { get; init; } = [];
}
}