OPC # 0001: Extract OPC into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:42 -04:00
commit 42383bdc03
170 changed files with 21365 additions and 0 deletions
+27
View File
@@ -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;
}
}
+90
View File
@@ -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()}";
}
+76
View File
@@ -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";
}
}
+119
View File
@@ -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;
}
}
+45
View File
@@ -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.");
}
}