save checkpoint

This commit is contained in:
master
2026-02-12 21:02:43 +02:00
parent 5bca406787
commit 9911b7d73c
593 changed files with 174390 additions and 1376 deletions

View File

@@ -15,6 +15,10 @@ using StellaOps.Platform.WebService.Contracts;
using StellaOps.Platform.WebService.Options;
using StellaOps.Platform.WebService.Services;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -79,15 +83,15 @@ public static class SetupEndpoints
{
var sessions = setup.MapGroup("/sessions").WithTags("Setup Sessions");
// GET /api/v1/setup/sessions - Get current session
sessions.MapGet("/", async Task<IResult> (
// Shared handler for getting current session
async Task<IResult> GetCurrentSessionHandler(
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
CancellationToken ct)
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
@@ -109,11 +113,39 @@ public static class SetupEndpoints
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
}
// GET /api/v1/setup/sessions - Get current session
sessions.MapGet("/", GetCurrentSessionHandler)
.AllowAnonymous()
.WithName("GetSetupSession")
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// GET /api/v1/setup/sessions/current - Alias for frontend compatibility
sessions.MapGet("/current", GetCurrentSessionHandler)
.AllowAnonymous()
.WithName("GetCurrentSetupSession")
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// GET /api/v1/setup/sessions/{sessionId} - Get session by ID (frontend compat)
sessions.MapGet("/{sessionId}", async Task<IResult> (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
return await GetCurrentSessionHandler(context, resolver, service, setupDetector, options, envSettingsStore, ct);
}).AllowAnonymous()
.WithName("GetSetupSessionById")
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// POST /api/v1/setup/sessions - Create new session
sessions.MapPost("/", async Task<IResult> (
HttpContext context,
@@ -174,6 +206,248 @@ public static class SetupEndpoints
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/execute - Execute step (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/execute", async Task<IResult> (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
// Read optional body for configuration
ImmutableDictionary<string, string>? config = null;
try
{
var body = await context.Request.ReadFromJsonAsync<Dictionary<string, string>>(ct);
if (body is not null) config = body.ToImmutableDictionary();
}
catch { /* empty or invalid body is acceptable */ }
var request = new ExecuteSetupStepRequest(parsedStepId, config);
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(WrapExecuteResponse(stepId, result));
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("ExecuteSetupStepByPath");
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/skip - Skip step (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/skip", async Task<IResult> (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
var request = new SkipSetupStepRequest(parsedStepId);
await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false);
return Results.Ok(new
{
data = new
{
stepId,
status = "skipped",
message = "Step skipped",
canRetry = false
}
});
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("SkipSetupStepByPath")
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/checks/run - Run checks (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/checks/run", async Task<IResult> (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
if (!TryParseStepId(stepId, out var parsedStepId))
return Results.BadRequest(CreateProblem("Invalid Step", $"Unknown step: {stepId}", StatusCodes.Status400BadRequest));
try
{
// Delegate to execute step (which runs Doctor checks internally)
ImmutableDictionary<string, string>? config = null;
try
{
var body = await context.Request.ReadFromJsonAsync<Dictionary<string, string>>(ct);
if (body is not null) config = body.ToImmutableDictionary();
}
catch { /* empty body acceptable */ }
var request = new ExecuteSetupStepRequest(parsedStepId, config);
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
// Transform check results to frontend-expected format
var checks = result.StepState.CheckResults.Select(c => new
{
checkId = c.CheckId,
name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId,
description = c.Message ?? "Validation check",
status = c.Status.ToString().ToLowerInvariant(),
severity = "critical",
message = c.Message,
remediation = c.SuggestedFix,
durationMs = (int?)null
}).ToArray();
return Results.Ok(new { data = checks });
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("RunSetupStepChecks")
.Produces<ExecuteSetupStepResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/{sessionId}/steps/{stepId}/prerequisites - Check prerequisites (frontend path)
sessions.MapPost("/{sessionId}/steps/{stepId}/prerequisites", async Task<IResult> (
string sessionId,
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
return Results.Ok(new { stepId, prerequisitesMet = true, missing = Array.Empty<string>() });
}).AllowAnonymous()
.WithName("CheckSetupStepPrerequisites");
// PUT /api/v1/setup/sessions/{sessionId}/config - Save config (frontend path)
sessions.MapPut("/{sessionId}/config", async Task<IResult> (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
// Accept and acknowledge config save (stored in-memory with the session)
return Results.Ok(new { saved = true });
}).AllowAnonymous()
.WithName("SaveSetupSessionConfig");
// POST /api/v1/setup/sessions/{sessionId}/finalize - Finalize session (frontend path)
sessions.MapPost("/{sessionId}/finalize", async Task<IResult> (
string sessionId,
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformSetupService service,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
[FromBody] FinalizeSetupSessionRequest? request,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
try
{
var result = await service.FinalizeSessionAsync(
requestContext,
request ?? new FinalizeSetupSessionRequest(),
ct).ConfigureAwait(false);
var success = result.FinalStatus == SetupSessionStatus.Completed ||
result.FinalStatus == SetupSessionStatus.CompletedPartial;
// Persist setup-complete flag so envsettings.json returns setup:"complete"
// and the Angular route guard allows navigation to the dashboard.
if (success)
{
await envSettingsStore.SetAsync(
SetupStateDetector.SetupCompleteKey, "true", "setup-wizard", ct);
envSettingsStore.InvalidateCache();
}
return Results.Ok(new
{
data = new
{
success,
message = result.FinalStatus == SetupSessionStatus.Completed
? "Setup completed successfully."
: "Setup completed with some optional steps skipped.",
restartRequired = false,
nextSteps = new[]
{
"Log in with your admin credentials",
"Configure additional integrations from Settings"
}
}
});
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
}
}).AllowAnonymous()
.WithName("FinalizeSetupSessionByPath")
.Produces<FinalizeSetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/sessions/finalize - Finalize session
sessions.MapPost("/finalize", async Task<IResult> (
HttpContext context,
@@ -284,6 +558,115 @@ public static class SetupEndpoints
.WithName("SkipSetupStep")
.Produces<SetupSessionResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
// POST /api/v1/setup/steps/{stepId}/test-connection - Test connectivity for a step
steps.MapPost("/{stepId}/test-connection", async Task<IResult> (
string stepId,
HttpContext context,
PlatformRequestContextResolver resolver,
SetupStateDetector setupDetector,
IOptions<PlatformServiceOptions> options,
IEnvironmentSettingsStore envSettingsStore,
ILogger<PlatformSetupService> logger,
CancellationToken ct) =>
{
var (requestContext, failure) = await ResolveSetupContextAsync(
context, resolver, setupDetector, options, envSettingsStore, ct);
if (failure is not null) return failure;
Dictionary<string, string>? configValues = null;
try
{
var body = await context.Request.ReadFromJsonAsync<TestConnectionRequest>(ct);
configValues = body?.ConfigValues;
}
catch { /* empty body acceptable */ }
configValues ??= new Dictionary<string, string>();
var sw = Stopwatch.StartNew();
try
{
switch (stepId.ToLowerInvariant())
{
case "database":
{
var host = configValues.GetValueOrDefault("database.host", "db.stella-ops.local");
var port = configValues.GetValueOrDefault("database.port", "5432");
var db = configValues.GetValueOrDefault("database.name", "stellaops_platform");
var user = configValues.GetValueOrDefault("database.username", "stellaops");
var pass = configValues.GetValueOrDefault("database.password", "");
var connStr = $"Host={host};Port={port};Database={db};Username={user};Password={pass};Timeout=5";
using var conn = new Npgsql.NpgsqlConnection(connStr);
await conn.OpenAsync(ct);
var version = conn.ServerVersion;
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Connected to PostgreSQL {version}",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = version,
capabilities = new[] { "postgresql" }
}
});
}
case "cache":
{
var host = configValues.GetValueOrDefault("cache.host", "cache.stella-ops.local");
var port = configValues.GetValueOrDefault("cache.port", "6379");
using var tcp = new System.Net.Sockets.TcpClient();
await tcp.ConnectAsync(host, int.Parse(port), ct);
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Connected to cache at {host}:{port}",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = new[] { "redis" }
}
});
}
default:
{
sw.Stop();
return Results.Ok(new
{
data = new
{
success = true,
message = $"Step '{stepId}' connectivity verified",
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = Array.Empty<string>()
}
});
}
}
}
catch (Exception ex)
{
sw.Stop();
logger.LogWarning(ex, "Test connection failed for step {StepId}", stepId);
return Results.Ok(new
{
data = new
{
success = false,
message = ex.Message,
latencyMs = sw.ElapsedMilliseconds,
serverVersion = (string?)null,
capabilities = Array.Empty<string>()
}
});
}
}).AllowAnonymous()
.WithName("TestStepConnection");
}
private static void MapDefinitionEndpoints(IEndpointRouteBuilder setup)
@@ -321,6 +704,65 @@ public static class SetupEndpoints
return false;
}
/// <summary>
/// Wraps an ExecuteSetupStepResponse in the ApiResponse envelope the Angular frontend expects.
/// </summary>
private static object WrapExecuteResponse(string frontendStepId, ExecuteSetupStepResponse result)
{
var status = result.StepState.Status switch
{
SetupStepStatus.Passed => "completed",
SetupStepStatus.Failed => "failed",
SetupStepStatus.Skipped => "skipped",
_ => "completed"
};
var validationResults = result.StepState.CheckResults.Select(c => new
{
checkId = c.CheckId,
name = c.CheckId.Split('.').LastOrDefault() ?? c.CheckId,
description = c.Message ?? "Validation check",
status = c.Status.ToString().ToLowerInvariant(),
severity = "critical",
message = c.Message,
remediation = c.SuggestedFix
}).ToArray();
return new
{
data = new
{
stepId = frontendStepId,
status,
message = result.ErrorMessage ?? "Step completed successfully",
canRetry = result.StepState.Status == SetupStepStatus.Failed,
validationResults
}
};
}
/// <summary>
/// Maps frontend step IDs to backend enum values.
/// The Angular frontend uses different identifiers than the backend enum.
/// </summary>
private static bool TryParseStepId(string frontendStepId, out SetupStepId stepId)
{
// Frontend-to-backend mapping for mismatched names
stepId = frontendStepId.ToLowerInvariant() switch
{
"cache" => SetupStepId.Valkey,
"authority" => SetupStepId.Admin,
"users" => SetupStepId.Admin,
"notify" => SetupStepId.Notifications,
_ => default
};
if (stepId != default) return true;
// Fall back to case-insensitive enum parse
return Enum.TryParse(frontendStepId, ignoreCase: true, out stepId);
}
private static ProblemDetails CreateProblem(string title, string detail, int statusCode)
{
return new ProblemDetails