OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class HandoffStep(ILogger<HandoffStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Handoff (Email Magic Link)";
|
||||
|
||||
public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: SendGrid / AWS SES
|
||||
// 1. Send email to context.Job.AdminEmail
|
||||
// 2. Include context.MagicLink for password setup
|
||||
// 3. Include login URL: https://{context.Job.Subdomain}
|
||||
logger.LogInformation("[{JobId}] Handoff step is a stub - email provider not yet wired.", context.Job.Id);
|
||||
context.Job.CompletedSteps |= CompletedSteps.HandoffSent;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// Email already sent cannot be recalled - log only
|
||||
logger.LogWarning("[{JobId}] Handoff step: email cannot be compensated if already sent.", context.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Worker.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class KeycloakStep(
|
||||
KeycloakAdminClient adminClient,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
ILogger<KeycloakStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Identity Bootstrapping (Keycloak)";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var realmId = RealmId(context);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating Keycloak realm {Realm}.", context.Job.Id, realmId);
|
||||
await adminClient.CreateRealmAsync(realmId, context.Job.ClientName, cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating AgencyAdmin role.", context.Job.Id);
|
||||
await adminClient.CreateRealmRoleAsync(realmId, "AgencyAdmin", "Day-zero administrator for this Clarity tenant.", cancellationToken);
|
||||
|
||||
// Derive the tenant's public-facing origin from ClarityInfraOptions.
|
||||
var tenantOrigin = infraOptions.Value.TenantPublicUrl(context.Job.Subdomain);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating Keycloak clients for realm {Realm} (origin: {Origin}).",
|
||||
context.Job.Id, realmId, tenantOrigin);
|
||||
|
||||
// clarity-rest-api: bearer-only resource server — just registers the audience so JWT validation passes.
|
||||
await adminClient.CreateClientAsync(realmId, new
|
||||
{
|
||||
clientId = "clarity-rest-api",
|
||||
name = "Clarity REST API",
|
||||
enabled = true,
|
||||
bearerOnly = true,
|
||||
publicClient = false,
|
||||
}, cancellationToken);
|
||||
|
||||
// clarity-web-app: public OIDC client used by the React frontend.
|
||||
await adminClient.CreateClientAsync(realmId, new
|
||||
{
|
||||
clientId = "clarity-web-app",
|
||||
name = "Clarity Web App",
|
||||
enabled = true,
|
||||
publicClient = true,
|
||||
standardFlowEnabled = true,
|
||||
directAccessGrantsEnabled = false,
|
||||
rootUrl = tenantOrigin,
|
||||
baseUrl = "/",
|
||||
redirectUris = new[] { $"{tenantOrigin}/*" },
|
||||
webOrigins = new[] { tenantOrigin },
|
||||
}, cancellationToken);
|
||||
|
||||
// Ensure tokens issued by clarity-web-app include "clarity-rest-api" in the `aud` claim
|
||||
// so that Clarity.Server JWT bearer validation (Audience = "clarity-rest-api") passes.
|
||||
logger.LogInformation("[{JobId}] Adding audience mapper for clarity-rest-api on clarity-web-app.", context.Job.Id);
|
||||
var webAppUuid = await adminClient.GetClientUuidAsync(realmId, "clarity-web-app", cancellationToken);
|
||||
await adminClient.AddAudienceMapperAsync(realmId, webAppUuid, "clarity-rest-api", cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Creating day-zero user {Email}.", context.Job.Id, context.Job.AdminEmail);
|
||||
var userId = await adminClient.CreateUserAsync(realmId, context.Job.AdminEmail, context.Job.ClientName, cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Assigning AgencyAdmin role.", context.Job.Id);
|
||||
await adminClient.AssignRealmRoleAsync(realmId, userId, "AgencyAdmin", cancellationToken);
|
||||
|
||||
// TODO No SMTP right now
|
||||
//logger.LogInformation("[{JobId}] Sending required actions email to {Email}.", context.Job.Id, context.Job.AdminEmail);
|
||||
//await adminClient.SendRequiredActionsEmailAsync(realmId, userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken);
|
||||
|
||||
context.DayZeroUserSubjectId = userId;
|
||||
//context.MagicLink = $"Action email sent to {context.Job.AdminEmail} for realm '{realmId}'.";
|
||||
context.Job.CompletedSteps |= CompletedSteps.KeycloakProvisioned;
|
||||
|
||||
logger.LogInformation("[{JobId}] Keycloak provisioning complete for realm {Realm}.", context.Job.Id, realmId);
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var realmId = RealmId(context);
|
||||
logger.LogWarning("[{JobId}] Compensating Keycloak - deleting realm {Realm}.", context.Job.Id, realmId);
|
||||
await adminClient.DeleteRealmAsync(realmId, cancellationToken);
|
||||
}
|
||||
|
||||
private static string RealmId(SagaContext context) =>
|
||||
$"clarity-{context.Job.Subdomain.ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Worker.Services;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Final saga step — launches the clarity-server Docker container with the fully
|
||||
/// enriched SagaContext (connection strings, Keycloak realm, etc. all known).
|
||||
/// Runs LAST so all env vars are available at container start.
|
||||
/// </summary>
|
||||
public class LaunchStep(
|
||||
ILogger<LaunchStep> logger,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
ClarityContainerService containers) : ISagaStep
|
||||
{
|
||||
public string StepName => "Container Launch";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = context.Job;
|
||||
|
||||
logger.LogInformation("[{JobId}] Launching container {Env}-app-clarity-{Site}",
|
||||
job.Id, job.Environment, job.SiteCode);
|
||||
|
||||
var containerName = await containers.StartTenantContainerAsync(
|
||||
environment: job.Environment,
|
||||
siteCode: job.SiteCode,
|
||||
subdomain: job.Subdomain,
|
||||
keycloakRealm: $"clarity-{job.Subdomain.ToLowerInvariant()}",
|
||||
postgresConnectionString: context.TenantConnectionString,
|
||||
vaultToken: ReadVaultToken(config),
|
||||
jobId: job.Id,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
context.ContainerName = containerName;
|
||||
context.TenantApiBaseUrl = infraOptions.Value.TenantPublicUrl(job.Subdomain);
|
||||
|
||||
logger.LogInformation("[{JobId}] Container {Name} live at {Url}",
|
||||
job.Id, containerName, context.TenantApiBaseUrl);
|
||||
|
||||
context.Job.CompletedSteps |= CompletedSteps.InfrastructureProvisioned;
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.ContainerName)) return;
|
||||
|
||||
logger.LogWarning("[{JobId}] Compensating: removing container {Name}", context.Job.Id, context.ContainerName);
|
||||
await containers.StopAndRemoveAsync(context.ContainerName, cancellationToken);
|
||||
await containers.RemoveNginxConfigAsync(context.Job.Subdomain, cancellationToken);
|
||||
}
|
||||
|
||||
// Reads the Vault root token from the persisted init.json on the Vault volume.
|
||||
// Falls back to config["Vault:Token"] then "root" for local dev.
|
||||
private static string? ReadVaultToken(IConfiguration config)
|
||||
{
|
||||
var keysFile = config["Vault:KeysFile"];
|
||||
if (!string.IsNullOrWhiteSpace(keysFile) && File.Exists(keysFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(keysFile);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("root_token", out var tok))
|
||||
return tok.GetString();
|
||||
}
|
||||
catch { /* fall through */ }
|
||||
}
|
||||
|
||||
return config["Vault:Token"] ?? "root";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using Npgsql;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Provisions a per-tenant Postgres database on the shared Postgres instance.
|
||||
/// Writes TenantConnectionString to SagaContext for downstream steps (LaunchStep).
|
||||
/// Compensation drops the database.
|
||||
/// </summary>
|
||||
public class MigrationStep(
|
||||
IConfiguration config,
|
||||
ILogger<MigrationStep> logger) : ISagaStep
|
||||
{
|
||||
public string StepName => "Database Migration & Seeding (EF Core)";
|
||||
|
||||
public async Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = context.Job;
|
||||
var dbName = TenantDbName(job.Subdomain);
|
||||
|
||||
var adminConnStr = config.GetConnectionString("postgres")
|
||||
?? throw new InvalidOperationException(
|
||||
"ConnectionStrings:postgres is missing. " +
|
||||
"Ensure ControlPlane.Worker has .WithReference(postgres) in AppHost.");
|
||||
|
||||
logger.LogInformation("[{JobId}] Provisioning database '{Db}'.", job.Id, dbName);
|
||||
await CreateDatabaseIfNotExistsAsync(adminConnStr, dbName, cancellationToken);
|
||||
|
||||
context.TenantConnectionString = BuildTenantConnectionString(adminConnStr, dbName);
|
||||
logger.LogInformation("[{JobId}] Database '{Db}' ready.", job.Id, dbName);
|
||||
|
||||
// TODO: Run EF Core migrations once dynamic DbContext is wired:
|
||||
// var opts = new DbContextOptionsBuilder<ApplicationDbContext>().UseNpgsql(context.TenantConnectionString).Options;
|
||||
// await using var db = new ApplicationDbContext(opts);
|
||||
// await db.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
context.Job.CompletedSteps |= CompletedSteps.DatabaseMigrated;
|
||||
}
|
||||
|
||||
public async Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(context.TenantConnectionString)) return;
|
||||
|
||||
var dbName = TenantDbName(context.Job.Subdomain);
|
||||
var adminConnStr = config.GetConnectionString("postgres");
|
||||
if (string.IsNullOrWhiteSpace(adminConnStr)) return;
|
||||
|
||||
logger.LogWarning("[{JobId}] Compensating: dropping database '{Db}'.", context.Job.Id, dbName);
|
||||
try
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(adminConnStr);
|
||||
await conn.OpenAsync(cancellationToken);
|
||||
|
||||
await using var terminate = conn.CreateCommand();
|
||||
terminate.CommandText = $"""
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = '{dbName}' AND pid <> pg_backend_pid();
|
||||
""";
|
||||
await terminate.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
await using var drop = conn.CreateCommand();
|
||||
drop.CommandText = $"DROP DATABASE IF EXISTS \"{dbName}\";";
|
||||
await drop.ExecuteNonQueryAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("[{JobId}] Dropped database '{Db}'.", context.Job.Id, dbName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Failed to drop database '{Db}' during compensation.", context.Job.Id, dbName);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Deterministic DB name from subdomain: fdev-app-clarity-01000014 → clarity_fdev_app_clarity_01000014
|
||||
internal static string TenantDbName(string subdomain) =>
|
||||
$"clarity_{subdomain.Replace('-', '_').ToLowerInvariant()}";
|
||||
|
||||
private static async Task CreateDatabaseIfNotExistsAsync(
|
||||
string adminConnStr, string dbName, CancellationToken ct)
|
||||
{
|
||||
await using var conn = new NpgsqlConnection(adminConnStr);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var check = conn.CreateCommand();
|
||||
check.CommandText = "SELECT 1 FROM pg_database WHERE datname = $1;";
|
||||
check.Parameters.AddWithValue(dbName);
|
||||
var exists = await check.ExecuteScalarAsync(ct) is not null;
|
||||
|
||||
if (!exists)
|
||||
{
|
||||
await using var create = conn.CreateCommand();
|
||||
// DB name is internally derived, not user input — safe to interpolate
|
||||
create.CommandText = $"CREATE DATABASE \"{dbName}\";";
|
||||
await create.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildTenantConnectionString(string adminConnStr, string dbName)
|
||||
{
|
||||
var b = new NpgsqlConnectionStringBuilder(adminConnStr) { Database = dbName };
|
||||
|
||||
// Tenant containers reach Postgres via the Aspire shared network using the stable
|
||||
// DNS alias "postgres" (the Aspire resource name) at the standard port 5432.
|
||||
// The port in the admin connection string is Aspire's random host-side proxy port —
|
||||
// reset it to 5432 so the in-network address is correct.
|
||||
if (b.Host is "localhost" or "127.0.0.1")
|
||||
{
|
||||
b.Host = "postgres";
|
||||
b.Port = 5432;
|
||||
}
|
||||
|
||||
return b.ConnectionString;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Models;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Worker.Steps;
|
||||
|
||||
public class VaultStep(ILogger<VaultStep> logger, IConfiguration config) : ISagaStep
|
||||
{
|
||||
public string StepName => "Cryptographic Pre-Flight (Vault)";
|
||||
|
||||
public Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: VaultSharp
|
||||
// 1. Assert Transit engine is active and healthy
|
||||
// 2. Derive/validate TenantContextId (e.g. FL_COM_001)
|
||||
// 3. Register TenantContextId in a KV entry or TenantRegistry table
|
||||
// so Clarity.Server can resolve the derivation path later
|
||||
//
|
||||
// Root token is read at runtime from the persisted init.json on the Vault volume:
|
||||
// var token = ReadRootToken();
|
||||
logger.LogInformation("[{JobId}] Vault step is a stub - VaultSharp not yet wired.", context.Job.Id);
|
||||
context.Job.CompletedSteps |= CompletedSteps.VaultVerified;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompensateAsync(SagaContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation("[{JobId}] Vault step: no compensation needed.", context.Job.Id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the root token from the init.json written by the Vault entrypoint on first boot.
|
||||
/// Path is injected via Vault__KeysFile config.
|
||||
/// </summary>
|
||||
internal string ReadRootToken()
|
||||
{
|
||||
var path = config["Vault__KeysFile"]
|
||||
?? throw new InvalidOperationException("Vault__KeysFile is not configured.");
|
||||
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(path));
|
||||
return doc.RootElement.GetProperty("root_token").GetString()
|
||||
?? throw new InvalidOperationException("root_token not found in Vault init.json.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user