save checkpoint
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// Sprint: SPRINT_20260112_004_PLATFORM_setup_wizard_backend (PLATFORM-SETUP-001)
|
||||
// Task: Define setup wizard contracts and step definitions
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
@@ -394,6 +395,12 @@ public sealed record SkipSetupStepRequest(
|
||||
public sealed record FinalizeSetupSessionRequest(
|
||||
bool Force = false);
|
||||
|
||||
/// <summary>
|
||||
/// Request to test connectivity for a setup step.
|
||||
/// </summary>
|
||||
public sealed record TestConnectionRequest(
|
||||
Dictionary<string, string>? ConfigValues = null);
|
||||
|
||||
#endregion
|
||||
|
||||
#region API Responses
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,11 +28,9 @@ public sealed class SetupStateDetector
|
||||
PlatformStorageOptions storage,
|
||||
IReadOnlyDictionary<string, string> dbSettings)
|
||||
{
|
||||
// 1. No DB configured → needs setup
|
||||
if (string.IsNullOrWhiteSpace(storage.PostgresConnectionString))
|
||||
return null;
|
||||
|
||||
// 2. Explicit SetupComplete key in DB
|
||||
// 1. Check explicit SetupComplete key in settings store (works with both
|
||||
// Postgres and in-memory stores, so finalize can signal completion even
|
||||
// before the DB schema exists).
|
||||
if (dbSettings.TryGetValue(SetupCompleteKey, out var value))
|
||||
{
|
||||
return string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -40,6 +38,10 @@ public sealed class SetupStateDetector
|
||||
: value; // step ID to resume at
|
||||
}
|
||||
|
||||
// 2. No DB configured and no SetupComplete flag → needs setup
|
||||
if (string.IsNullOrWhiteSpace(storage.PostgresConnectionString))
|
||||
return null;
|
||||
|
||||
// 3. No SetupComplete key but other settings exist → existing deployment (upgrade scenario)
|
||||
if (dbSettings.Count > 0)
|
||||
return "complete";
|
||||
|
||||
Reference in New Issue
Block a user