777 lines
33 KiB
C#
777 lines
33 KiB
C#
// Copyright (c) StellaOps. All rights reserved.
|
|
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
|
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-003)
|
|
// Task: Add /api/v1/setup/* endpoints with auth policies, request validation, and Problem+JSON errors
|
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using StellaOps.Platform.WebService.Constants;
|
|
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;
|
|
|
|
namespace StellaOps.Platform.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Setup wizard API endpoints aligned to docs/setup/setup-wizard-ux.md.
|
|
/// All endpoints are AllowAnonymous because during initial setup the Authority
|
|
/// service is not running. When setup is already complete, the handlers
|
|
/// enforce auth via TryResolveContext before proceeding.
|
|
/// </summary>
|
|
public static class SetupEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapSetupEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var setup = app.MapGroup("/api/v1/setup")
|
|
.WithTags("Setup Wizard");
|
|
|
|
MapSessionEndpoints(setup);
|
|
MapStepEndpoints(setup);
|
|
MapDefinitionEndpoints(setup);
|
|
|
|
return app;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the request context, falling back to a bootstrap context during initial setup.
|
|
/// When setup is complete, requires authenticated context (returns error on failure).
|
|
/// </summary>
|
|
private static async Task<(PlatformRequestContext Context, IResult? Failure)> ResolveSetupContextAsync(
|
|
HttpContext httpContext,
|
|
PlatformRequestContextResolver resolver,
|
|
SetupStateDetector setupDetector,
|
|
IOptions<PlatformServiceOptions> options,
|
|
IEnvironmentSettingsStore envSettingsStore,
|
|
CancellationToken ct)
|
|
{
|
|
var dbSettings = await envSettingsStore.GetAllAsync(ct);
|
|
var setupState = setupDetector.Detect(options.Value.Storage, dbSettings);
|
|
|
|
if (setupState == "complete")
|
|
{
|
|
// Setup already done — require auth for re-configuration
|
|
if (!TryResolveContext(httpContext, resolver, out var authContext, out var failure))
|
|
{
|
|
return (null!, failure);
|
|
}
|
|
return (authContext!, null);
|
|
}
|
|
|
|
// During initial setup, resolve context best-effort
|
|
if (!resolver.TryResolve(httpContext, out var requestContext, out _))
|
|
{
|
|
// No tenant/auth available — use bootstrap context
|
|
requestContext = new PlatformRequestContext("setup", "setup-wizard", null);
|
|
}
|
|
|
|
return (requestContext!, null);
|
|
}
|
|
|
|
private static void MapSessionEndpoints(IEndpointRouteBuilder setup)
|
|
{
|
|
var sessions = setup.MapGroup("/sessions").WithTags("Setup Sessions");
|
|
|
|
// 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)
|
|
{
|
|
var (requestContext, failure) = await ResolveSetupContextAsync(
|
|
context, resolver, setupDetector, options, envSettingsStore, ct);
|
|
if (failure is not null) return failure;
|
|
|
|
try
|
|
{
|
|
var result = await service.GetSessionAsync(requestContext, ct).ConfigureAwait(false);
|
|
if (result is null)
|
|
{
|
|
return Results.NotFound(CreateProblem(
|
|
"Session Not Found",
|
|
"No active setup session for this tenant.",
|
|
StatusCodes.Status404NotFound));
|
|
}
|
|
return Results.Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
PlatformRequestContextResolver resolver,
|
|
PlatformSetupService service,
|
|
SetupStateDetector setupDetector,
|
|
IOptions<PlatformServiceOptions> options,
|
|
IEnvironmentSettingsStore envSettingsStore,
|
|
[FromBody] CreateSetupSessionRequest? 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.CreateSessionAsync(
|
|
requestContext,
|
|
request ?? new CreateSetupSessionRequest(),
|
|
ct).ConfigureAwait(false);
|
|
return Results.Created($"/api/v1/setup/sessions", result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}).AllowAnonymous()
|
|
.WithName("CreateSetupSession")
|
|
.Produces<SetupSessionResponse>(StatusCodes.Status201Created)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
|
|
|
// POST /api/v1/setup/sessions/resume - Resume or create session
|
|
sessions.MapPost("/resume", async Task<IResult> (
|
|
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;
|
|
|
|
try
|
|
{
|
|
var result = await service.ResumeOrCreateSessionAsync(requestContext, ct).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}).AllowAnonymous()
|
|
.WithName("ResumeSetupSession")
|
|
.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,
|
|
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);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}).AllowAnonymous()
|
|
.WithName("FinalizeSetupSession")
|
|
.Produces<FinalizeSetupSessionResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
private static void MapStepEndpoints(IEndpointRouteBuilder setup)
|
|
{
|
|
var steps = setup.MapGroup("/steps").WithTags("Setup Steps");
|
|
|
|
// POST /api/v1/setup/steps/execute - Execute a step
|
|
steps.MapPost("/execute", async Task<IResult> (
|
|
HttpContext context,
|
|
PlatformRequestContextResolver resolver,
|
|
PlatformSetupService service,
|
|
SetupStateDetector setupDetector,
|
|
IOptions<PlatformServiceOptions> options,
|
|
IEnvironmentSettingsStore envSettingsStore,
|
|
[FromBody] ExecuteSetupStepRequest request,
|
|
CancellationToken ct) =>
|
|
{
|
|
var (requestContext, failure) = await ResolveSetupContextAsync(
|
|
context, resolver, setupDetector, options, envSettingsStore, ct);
|
|
if (failure is not null) return failure;
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(CreateProblem(
|
|
"Invalid Request",
|
|
"Request body is required with stepId.",
|
|
StatusCodes.Status400BadRequest));
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await service.ExecuteStepAsync(requestContext, request, ct).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}).AllowAnonymous()
|
|
.WithName("ExecuteSetupStep")
|
|
.Produces<ExecuteSetupStepResponse>(StatusCodes.Status200OK)
|
|
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest);
|
|
|
|
// POST /api/v1/setup/steps/skip - Skip a step
|
|
steps.MapPost("/skip", async Task<IResult> (
|
|
HttpContext context,
|
|
PlatformRequestContextResolver resolver,
|
|
PlatformSetupService service,
|
|
SetupStateDetector setupDetector,
|
|
IOptions<PlatformServiceOptions> options,
|
|
IEnvironmentSettingsStore envSettingsStore,
|
|
[FromBody] SkipSetupStepRequest request,
|
|
CancellationToken ct) =>
|
|
{
|
|
var (requestContext, failure) = await ResolveSetupContextAsync(
|
|
context, resolver, setupDetector, options, envSettingsStore, ct);
|
|
if (failure is not null) return failure;
|
|
|
|
if (request is null)
|
|
{
|
|
return Results.BadRequest(CreateProblem(
|
|
"Invalid Request",
|
|
"Request body is required with stepId.",
|
|
StatusCodes.Status400BadRequest));
|
|
}
|
|
|
|
try
|
|
{
|
|
var result = await service.SkipStepAsync(requestContext, request, ct).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return Results.BadRequest(CreateProblem("Invalid Operation", ex.Message, StatusCodes.Status400BadRequest));
|
|
}
|
|
}).AllowAnonymous()
|
|
.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)
|
|
{
|
|
var definitions = setup.MapGroup("/definitions").WithTags("Setup Definitions");
|
|
|
|
// GET /api/v1/setup/definitions/steps - Get all step definitions
|
|
definitions.MapGet("/steps", async Task<IResult> (
|
|
PlatformSetupService service,
|
|
CancellationToken ct) =>
|
|
{
|
|
var result = await service.GetStepDefinitionsAsync(ct).ConfigureAwait(false);
|
|
return Results.Ok(result);
|
|
}).AllowAnonymous()
|
|
.WithName("GetSetupStepDefinitions")
|
|
.Produces<SetupStepDefinitionsResponse>(StatusCodes.Status200OK);
|
|
}
|
|
|
|
private static bool TryResolveContext(
|
|
HttpContext context,
|
|
PlatformRequestContextResolver resolver,
|
|
out PlatformRequestContext? requestContext,
|
|
out IResult? failure)
|
|
{
|
|
if (resolver.TryResolve(context, out requestContext, out var error))
|
|
{
|
|
failure = null;
|
|
return true;
|
|
}
|
|
|
|
failure = Results.BadRequest(CreateProblem(
|
|
"Context Resolution Failed",
|
|
error ?? "Unable to resolve tenant context.",
|
|
StatusCodes.Status400BadRequest));
|
|
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
|
|
{
|
|
Title = title,
|
|
Detail = detail,
|
|
Status = statusCode,
|
|
Type = $"https://stella.ops/problems/{title.ToLowerInvariant().Replace(' ', '-')}"
|
|
};
|
|
}
|
|
}
|