OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>controlplane-worker-secrets</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Keycloak.AuthServices.Sdk" />
|
||||
<PackageReference Include="MassTransit" />
|
||||
<PackageReference Include="MassTransit.RabbitMQ" />
|
||||
<PackageReference Include="Aspire.RabbitMQ.Client" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Clarity.ServiceDefaults\Clarity.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\ControlPlane.Core\ControlPlane.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Properties\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,30 @@
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY ["ControlPlane.Worker/ControlPlane.Worker.csproj", "ControlPlane.Worker/"]
|
||||
COPY ["ControlPlane.Core/ControlPlane.Core.csproj", "ControlPlane.Core/"]
|
||||
COPY ["Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj", "Clarity.ServiceDefaults/"]
|
||||
COPY ["Directory.Packages.props", "./"]
|
||||
|
||||
RUN dotnet restore "ControlPlane.Worker/ControlPlane.Worker.csproj"
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish "ControlPlane.Worker/ControlPlane.Worker.csproj" \
|
||||
-c Release -o /app/publish --no-restore
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install Pulumi CLI so the Automation API can shell out to it
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://get.pulumi.com | sh \
|
||||
&& apt-get purge -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.pulumi/bin:${PATH}"
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"]
|
||||
@@ -0,0 +1,64 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Services;
|
||||
using ControlPlane.Worker;
|
||||
using ControlPlane.Worker.Services;
|
||||
using ControlPlane.Worker.Steps;
|
||||
using Keycloak.AuthServices.Sdk;
|
||||
using MassTransit;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
// Centralized infrastructure options — domain, network, internal URLs, cert paths
|
||||
builder.Services.Configure<ClarityInfraOptions>(
|
||||
builder.Configuration.GetSection(ClarityInfraOptions.Section));
|
||||
|
||||
// Keycloak Admin SDK client
|
||||
builder.Services.AddKeycloakAdminHttpClient(o =>
|
||||
{
|
||||
o.AuthServerUrl = builder.Configuration["Keycloak:AuthServerUrl"] ?? "http://localhost:8080";
|
||||
o.Realm = builder.Configuration["Keycloak:Realm"] ?? "master";
|
||||
o.Resource = builder.Configuration["Keycloak:Resource"] ?? "admin-cli";
|
||||
});
|
||||
|
||||
// Custom admin client - handles realm creation, roles, role assignment (not in SDK)
|
||||
builder.Services.AddSingleton<KeycloakAdminClient>();
|
||||
|
||||
// Docker container manager for per-tenant Clarity.Server instances
|
||||
builder.Services.AddSingleton<ClarityContainerService>();
|
||||
|
||||
// Tenant registry - persists provisioned tenant XML files to ClientAssets folder
|
||||
builder.Services.AddSingleton<TenantRegistryService>();
|
||||
|
||||
// Saga steps in execution order — container launches LAST once all context is populated
|
||||
builder.Services.AddSingleton<ISagaStep, KeycloakStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, VaultStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, MigrationStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, LaunchStep>();
|
||||
builder.Services.AddSingleton<ISagaStep, HandoffStep>();
|
||||
|
||||
builder.Services.AddMassTransit(x =>
|
||||
{
|
||||
x.SetKebabCaseEndpointNameFormatter();
|
||||
|
||||
x.AddConsumer<ProvisioningConsumer>();
|
||||
|
||||
x.UsingRabbitMq((ctx, cfg) =>
|
||||
{
|
||||
cfg.Host(builder.Configuration.GetConnectionString("rabbitmq"));
|
||||
cfg.ConfigureEndpoints(ctx);
|
||||
});
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
var host = builder.Build();
|
||||
host.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"FATAL WORKER CRASH: {ex}");
|
||||
throw;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Interfaces;
|
||||
using ControlPlane.Core.Messages;
|
||||
using ControlPlane.Core.Models;
|
||||
using ControlPlane.Core.Services;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker;
|
||||
|
||||
/// <summary>
|
||||
/// MassTransit consumer. Triggered by ProvisionClientCommand off RabbitMQ.
|
||||
/// Runs the saga and publishes ProvisioningProgressEvent for each step transition.
|
||||
/// </summary>
|
||||
public sealed class ProvisioningConsumer(
|
||||
IEnumerable<ISagaStep> steps,
|
||||
IPublishEndpoint bus,
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
TenantRegistryService registry,
|
||||
ILogger<ProvisioningConsumer> logger) : IConsumer<ProvisionClientCommand>
|
||||
{
|
||||
public async Task Consume(ConsumeContext<ProvisionClientCommand> context)
|
||||
{
|
||||
var cmd = context.Message;
|
||||
var job = new ProvisioningJob
|
||||
{
|
||||
Id = cmd.JobId,
|
||||
ClientName = cmd.ClientName,
|
||||
StateCode = cmd.StateCode,
|
||||
Subdomain = cmd.Subdomain,
|
||||
AdminEmail = cmd.AdminEmail,
|
||||
SiteCode = cmd.SiteCode,
|
||||
Environment = cmd.Environment,
|
||||
Status = ProvisioningStatus.Running
|
||||
};
|
||||
|
||||
logger.LogInformation("Starting provisioning saga for job {JobId} ({Client})", job.Id, job.ClientName);
|
||||
await RunSagaAsync(job, context.CancellationToken);
|
||||
}
|
||||
|
||||
private async Task RunSagaAsync(ProvisioningJob job, CancellationToken cancellationToken)
|
||||
{
|
||||
var sagaContext = new SagaContext { Job = job };
|
||||
var executedSteps = new Stack<ISagaStep>();
|
||||
|
||||
foreach (var step in steps)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Publish(job.Id, "step_started", step.StepName, $"Starting: {step.StepName}");
|
||||
logger.LogInformation("[{JobId}] Executing: {Step}", job.Id, step.StepName);
|
||||
|
||||
await step.ExecuteAsync(sagaContext, cancellationToken);
|
||||
executedSteps.Push(step);
|
||||
|
||||
await Publish(job.Id, "step_complete", step.StepName, $"Completed: {step.StepName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Step {Step} failed", job.Id, step.StepName);
|
||||
await Publish(job.Id, "step_failed", step.StepName, $"Failed: {step.StepName} - {ex.Message}");
|
||||
await PublishDiagnostic(job.Id, step.StepName, ex);
|
||||
|
||||
job.Status = ProvisioningStatus.Compensating;
|
||||
job.FailureReason = $"{step.StepName}: {ex.Message}";
|
||||
|
||||
await CompensateAsync(sagaContext, executedSteps, cancellationToken);
|
||||
|
||||
job.Status = ProvisioningStatus.Failed;
|
||||
await Publish(job.Id, "job_failed", null, job.FailureReason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
job.Status = ProvisioningStatus.Completed;
|
||||
job.CompletedAt = DateTimeOffset.UtcNow;
|
||||
await Publish(job.Id, "job_complete", null, "All steps completed successfully.");
|
||||
|
||||
var infra = infraOptions.Value;
|
||||
var apiBaseUrl = sagaContext.TenantApiBaseUrl
|
||||
?? infra.TenantPublicUrl(job.Subdomain);
|
||||
|
||||
// Persist to ClientAssets/{subdomain}.xml
|
||||
var nginxConfPath = config["Nginx:ConfDPath"] is { } p
|
||||
? Path.Combine(p, $"{job.Subdomain}.conf")
|
||||
: null;
|
||||
|
||||
var record = new TenantRecord
|
||||
{
|
||||
JobId = job.Id.ToString(),
|
||||
Subdomain = job.Subdomain,
|
||||
ClientName = job.ClientName,
|
||||
StateCode = job.StateCode,
|
||||
AdminEmail = job.AdminEmail,
|
||||
SiteCode = job.SiteCode,
|
||||
Environment = job.Environment,
|
||||
Tier = job.Tier.ToString(),
|
||||
ApiBaseUrl = apiBaseUrl,
|
||||
Status = "Provisioned",
|
||||
ProvisionedAt = job.CompletedAt!.Value.ToString("o"),
|
||||
ContainerName = sagaContext.ContainerName,
|
||||
ContainerPort = null,
|
||||
ContainerImage = config["Docker:ClarityServerImage"] ?? "clarity-server:latest",
|
||||
ContainerNetwork = infra.Network,
|
||||
NginxConfPath = nginxConfPath,
|
||||
PublicUrl = infra.TenantPublicUrl(job.Subdomain),
|
||||
LastProvisioningStep = "LaunchStep",
|
||||
ProvisioningNotes = $"Provisioned at {job.CompletedAt:o}. All {job.CompletedSteps} steps completed.",
|
||||
};
|
||||
|
||||
// AppSettings — enriched by each step via SagaContext
|
||||
record.SetAppSetting("Keycloak:Realm", $"clarity-{job.Subdomain.ToLowerInvariant()}");
|
||||
record.SetAppSetting("Keycloak:BaseUrl", infra.KeycloakPublicUrl);
|
||||
record.SetAppSetting("Keycloak:InternalUrl", infra.KeycloakInternalUrl);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(sagaContext.TenantStackName))
|
||||
record.SetAppSetting("Pulumi:StackName", sagaContext.TenantStackName);
|
||||
|
||||
// ConnectionStrings — written by MigrationStep once DB is provisioned
|
||||
if (!string.IsNullOrWhiteSpace(sagaContext.TenantConnectionString))
|
||||
record.SetConnectionString("TenantDb", sagaContext.TenantConnectionString);
|
||||
|
||||
registry.Save(record);
|
||||
|
||||
logger.LogInformation("[{JobId}] Provisioning completed. Tenant record saved.", job.Id);
|
||||
}
|
||||
|
||||
private async Task CompensateAsync(SagaContext sagaContext, Stack<ISagaStep> executedSteps, CancellationToken cancellationToken)
|
||||
{
|
||||
while (executedSteps.TryPop(out var step))
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("[{JobId}] Compensating: {Step}", sagaContext.Job.Id, step.StepName);
|
||||
await Publish(sagaContext.Job.Id, "compensation_started", step.StepName, $"Rolling back: {step.StepName}");
|
||||
await step.CompensateAsync(sagaContext, cancellationToken);
|
||||
await Publish(sagaContext.Job.Id, "compensation_complete", step.StepName, $"Rolled back: {step.StepName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "[{JobId}] Compensation failed for {Step} - manual intervention required", sagaContext.Job.Id, step.StepName);
|
||||
await PublishDiagnostic(sagaContext.Job.Id, $"{step.StepName} (compensation)", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task Publish(Guid jobId, string type, string? step, string? message) =>
|
||||
bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = type,
|
||||
Step = step,
|
||||
Message = message
|
||||
});
|
||||
|
||||
private Task PublishDiagnostic(Guid jobId, string? step, Exception ex) =>
|
||||
bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "diagnostic",
|
||||
Step = step,
|
||||
Message = ex.Message,
|
||||
Detail = ex.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
using ControlPlane.Core.Config;
|
||||
using ControlPlane.Core.Messages;
|
||||
using Docker.DotNet;
|
||||
using Docker.DotNet.Models;
|
||||
using MassTransit;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ControlPlane.Worker.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages Clarity.Server Docker containers for provisioned tenants.
|
||||
/// Container naming convention: {env}-app-clarity-{siteCode}
|
||||
/// e.g. fdev-app-clarity-01000014
|
||||
/// </summary>
|
||||
public class ClarityContainerService(
|
||||
IConfiguration config,
|
||||
IOptions<ClarityInfraOptions> infraOptions,
|
||||
IPublishEndpoint bus,
|
||||
ILogger<ClarityContainerService> logger)
|
||||
{
|
||||
private ClarityInfraOptions Infra => infraOptions.Value;
|
||||
|
||||
// The image to run - override via config for prod registries
|
||||
private string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
|
||||
|
||||
private DockerClient CreateClient()
|
||||
{
|
||||
var uri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
|
||||
return new DockerClientConfiguration(new Uri(uri)).CreateClient();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derives the container name from environment + siteCode.
|
||||
/// Convention: {env}-app-clarity-{siteCode}
|
||||
/// </summary>
|
||||
public static string ContainerName(string environment, string siteCode) =>
|
||||
$"{environment.ToLowerInvariant()}-app-clarity-{siteCode.ToLowerInvariant()}";
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the image (if not present locally), starts the container on the managed network,
|
||||
/// and writes an nginx conf.d snippet so traffic routes in.
|
||||
/// No host port binding — nginx reaches the container via Docker DNS on the shared network.
|
||||
/// </summary>
|
||||
public async Task<string> StartTenantContainerAsync(
|
||||
string environment,
|
||||
string siteCode,
|
||||
string subdomain,
|
||||
string keycloakRealm,
|
||||
string? postgresConnectionString,
|
||||
string? vaultToken,
|
||||
Guid jobId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
var name = ContainerName(environment, siteCode);
|
||||
|
||||
// Stop and remove any existing container with this name (idempotent reprovision)
|
||||
await TryRemoveExistingAsync(docker, name, cancellationToken);
|
||||
|
||||
// Pull image if not already local
|
||||
await EnsureImageAsync(docker, cancellationToken);
|
||||
|
||||
// All service URLs use stable Docker DNS names on the managed network — no host ports involved.
|
||||
var container = await docker.Containers.CreateContainerAsync(new CreateContainerParameters
|
||||
{
|
||||
Name = name,
|
||||
Image = ImageName,
|
||||
Env =
|
||||
[
|
||||
"ASPNETCORE_ENVIRONMENT=Production",
|
||||
"ASPNETCORE_URLS=http://+:8080",
|
||||
$"TenantSubdomain={subdomain}",
|
||||
$"Keycloak__BaseUrl={Infra.KeycloakPublicUrl}",
|
||||
$"Keycloak__InternalUrl={Infra.KeycloakInternalUrl}",
|
||||
$"Keycloak__Realm={keycloakRealm}",
|
||||
$"Vault__Address={Infra.VaultInternalUrl}",
|
||||
.. (vaultToken is not null
|
||||
? (string[])[$"Vault__Token={vaultToken}"]
|
||||
: []),
|
||||
.. (postgresConnectionString is not null
|
||||
? (string[])[$"ConnectionStrings__postgresdb={postgresConnectionString}"]
|
||||
: []),
|
||||
],
|
||||
HostConfig = new HostConfig
|
||||
{
|
||||
NetworkMode = Infra.Network,
|
||||
RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped },
|
||||
},
|
||||
Labels = new Dictionary<string, string>
|
||||
{
|
||||
["clarity.managed"] = "true",
|
||||
["clarity.subdomain"] = subdomain,
|
||||
["clarity.siteCode"] = siteCode,
|
||||
["clarity.env"] = environment,
|
||||
},
|
||||
}, cancellationToken);
|
||||
|
||||
// Ensure Keycloak and Vault are reachable on the managed network via their Docker DNS aliases.
|
||||
// Aspire places them on its own bridge; tenant containers on clarity-net need them aliased here.
|
||||
await EnsureContainerOnNetworkAsync(docker, "keycloak", Infra.Network, "keycloak", cancellationToken);
|
||||
await EnsureContainerOnNetworkAsync(docker, "vault", Infra.Network, "vault", cancellationToken);
|
||||
|
||||
var started = await docker.Containers.StartContainerAsync(container.ID, null, cancellationToken);
|
||||
if (!started)
|
||||
throw new InvalidOperationException($"Docker failed to start container {name} (id={container.ID}).");
|
||||
|
||||
logger.LogInformation("Started container {Name} on {Network} (image: {Image})", name, Infra.Network, ImageName);
|
||||
|
||||
await WriteNginxConfigAsync(subdomain, name, jobId, cancellationToken);
|
||||
|
||||
return name;
|
||||
}
|
||||
/// <summary>
|
||||
/// Stops and removes a tenant container. Called from InfrastructureStep.CompensateAsync.
|
||||
/// </summary>
|
||||
public async Task StopAndRemoveAsync(string containerName, CancellationToken cancellationToken)
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
await TryRemoveExistingAsync(docker, containerName, cancellationToken);
|
||||
logger.LogInformation("Removed container {Name}", containerName);
|
||||
}
|
||||
|
||||
// -- helpers --
|
||||
|
||||
private async Task EnsureImageAsync(DockerClient docker, CancellationToken cancellationToken)
|
||||
{
|
||||
var images = await docker.Images.ListImagesAsync(new ImagesListParameters
|
||||
{
|
||||
Filters = new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["reference"] = new Dictionary<string, bool> { [ImageName] = true }
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
if (images.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Image {Image} already present locally.", ImageName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Local image (no registry host) — pulling from Docker Hub will always fail.
|
||||
// The image must be built manually before provisioning.
|
||||
var isLocalOnly = !ImageName.Contains('/') || ImageName.StartsWith("localhost/");
|
||||
if (isLocalOnly)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Image '{ImageName}' was not found locally and cannot be pulled from a registry. " +
|
||||
$"Build it first from the repo root:{Environment.NewLine}" +
|
||||
$" docker build -f Clarity.Server/Dockerfile -t {ImageName} ." +
|
||||
$"{Environment.NewLine}Then retry provisioning.");
|
||||
}
|
||||
|
||||
// Registry image — attempt pull
|
||||
logger.LogInformation("Pulling image {Image} from registry...", ImageName);
|
||||
var (repo, tag) = SplitImageTag(ImageName);
|
||||
await docker.Images.CreateImageAsync(
|
||||
new ImagesCreateParameters { FromImage = repo, Tag = tag },
|
||||
null,
|
||||
new Progress<JSONMessage>(m =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(m.Status))
|
||||
logger.LogDebug("[docker pull] {Status} {Progress}", m.Status, m.ProgressMessage);
|
||||
}),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
// -- nginx conf.d helpers --
|
||||
|
||||
/// <summary>
|
||||
/// Writes /NginxConfig/conf.d/{subdomain}.conf so nginx routes
|
||||
/// {subdomain}.clarity.test → the containe
|
||||
/// Then signals nginx to reload its config without dropping connections.
|
||||
/// </summary>
|
||||
private async Task WriteNginxConfigAsync(string subdomain, string containerName, Guid jobId, CancellationToken ct)
|
||||
{
|
||||
var confDPath = config["Nginx:ConfDPath"];
|
||||
if (string.IsNullOrWhiteSpace(confDPath))
|
||||
{
|
||||
logger.LogWarning("Nginx:ConfDPath is not configured — skipping nginx conf write for {Subdomain}.", subdomain);
|
||||
return;
|
||||
}
|
||||
|
||||
var confContent = $$$"""
|
||||
# Auto-generated by ControlPlane.Worker — do not edit manually.
|
||||
# Tenant: {{{subdomain}}}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name {{{subdomain}}}.{{{Infra.Domain}}};
|
||||
|
||||
ssl_certificate {{{Infra.NginxCertPath}}};
|
||||
ssl_certificate_key {{{Infra.NginxCertKeyPath}}};
|
||||
|
||||
location / {
|
||||
# Docker DNS resolves the container name on the managed network
|
||||
set $upstream http://{{{containerName}}}:8080;
|
||||
proxy_pass $upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var confFile = Path.Combine(confDPath, $"{subdomain}.conf");
|
||||
await File.WriteAllTextAsync(confFile, confContent, ct);
|
||||
logger.LogInformation("Wrote nginx config for {Subdomain} → {Container}", subdomain, containerName);
|
||||
|
||||
await ReloadNginxAsync(jobId, subdomain, ct);
|
||||
}
|
||||
|
||||
public async Task RemoveNginxConfigAsync(string subdomain, CancellationToken ct)
|
||||
{
|
||||
var confDPath = config["Nginx:ConfDPath"];
|
||||
if (string.IsNullOrWhiteSpace(confDPath)) return;
|
||||
|
||||
var confFile = Path.Combine(confDPath, $"{subdomain}.conf");
|
||||
if (File.Exists(confFile))
|
||||
{
|
||||
File.Delete(confFile);
|
||||
logger.LogInformation("Removed nginx config for {Subdomain}", subdomain);
|
||||
await ReloadNginxAsync(Guid.Empty, subdomain, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends SIGHUP to the nginx container which triggers a graceful config reload.
|
||||
private async Task ReloadNginxAsync(Guid jobId, string subdomain, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var docker = CreateClient();
|
||||
|
||||
// Find the nginx container by image name — Aspire appends a random suffix to the name
|
||||
// so we can't rely on the static name "nginx".
|
||||
var containers = await docker.Containers.ListContainersAsync(
|
||||
new ContainersListParameters
|
||||
{
|
||||
Filters = new Dictionary<string, IDictionary<string, bool>>
|
||||
{
|
||||
["ancestor"] = new Dictionary<string, bool> { ["nginx"] = true }
|
||||
}
|
||||
}, ct);
|
||||
|
||||
var nginx = containers.FirstOrDefault();
|
||||
if (nginx is null)
|
||||
{
|
||||
logger.LogWarning("nginx container not found — skipping reload.");
|
||||
return;
|
||||
}
|
||||
|
||||
await docker.Containers.KillContainerAsync(nginx.ID, new ContainerKillParameters { Signal = "HUP" }, ct);
|
||||
var containerName = nginx.Names.FirstOrDefault() ?? nginx.ID;
|
||||
logger.LogInformation("nginx reloaded (container: {Name}).", containerName);
|
||||
|
||||
if (jobId != Guid.Empty)
|
||||
{
|
||||
await bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "nginx_reloaded",
|
||||
Step = "Container Launch",
|
||||
Message = $"nginx reloaded — route for {subdomain}.{Infra.Domain} is live.",
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to reload nginx — new tenant route may not be active until next nginx restart.");
|
||||
|
||||
if (jobId != Guid.Empty)
|
||||
{
|
||||
await bus.Publish(new ProvisioningProgressEvent
|
||||
{
|
||||
JobId = jobId,
|
||||
Type = "diagnostic",
|
||||
Step = "Container Launch",
|
||||
Message = "nginx reload failed — route may not be active.",
|
||||
Detail = ex.ToString(),
|
||||
}, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- docker helpers --
|
||||
|
||||
private static async Task TryRemoveExistingAsync(DockerClient docker, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await docker.Containers.StopContainerAsync(name,
|
||||
new ContainerStopParameters { WaitBeforeKillSeconds = 5 }, cancellationToken);
|
||||
await docker.Containers.RemoveContainerAsync(name,
|
||||
new ContainerRemoveParameters { Force = true }, cancellationToken);
|
||||
}
|
||||
catch (DockerContainerNotFoundException) { /* already gone - fine */ }
|
||||
catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { /* same */ }
|
||||
}
|
||||
|
||||
private static (string repo, string tag) SplitImageTag(string image)
|
||||
{
|
||||
var colon = image.LastIndexOf(':');
|
||||
return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects <paramref name="containerName"/> to <paramref name="network"/> with the given
|
||||
/// <paramref name="alias"/> if it isn't already connected.
|
||||
/// Silently no-ops if the container isn't found (it may not be running in all environments).
|
||||
/// </summary>
|
||||
private async Task EnsureContainerOnNetworkAsync(
|
||||
DockerClient docker,
|
||||
string containerName,
|
||||
string network,
|
||||
string alias,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inspect = await docker.Containers.InspectContainerAsync(containerName, cancellationToken);
|
||||
|
||||
if (inspect.NetworkSettings.Networks.TryGetValue(network, out var existing))
|
||||
{
|
||||
// Already connected — check whether our alias is present.
|
||||
var hasAlias = existing.Aliases?.Contains(alias, StringComparer.OrdinalIgnoreCase) == true;
|
||||
if (hasAlias) return;
|
||||
|
||||
// Connected but without the alias — disconnect so we can reconnect with it.
|
||||
await docker.Networks.DisconnectNetworkAsync(network, new NetworkDisconnectParameters
|
||||
{
|
||||
Container = inspect.ID,
|
||||
Force = true,
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
await docker.Networks.ConnectNetworkAsync(network, new NetworkConnectParameters
|
||||
{
|
||||
Container = inspect.ID,
|
||||
EndpointConfig = new EndpointSettings
|
||||
{
|
||||
Aliases = [alias],
|
||||
},
|
||||
}, cancellationToken);
|
||||
logger.LogInformation("Connected container '{Container}' to network '{Network}' with alias '{Alias}'.", containerName, network, alias);
|
||||
}
|
||||
catch (DockerContainerNotFoundException)
|
||||
{
|
||||
logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName);
|
||||
}
|
||||
catch (DockerApiException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
logger.LogWarning("Container '{Container}' not found — skipping network connect.", containerName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not connect '{Container}' to '{Network}' — tenant JWT validation may fail.", containerName, network);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ControlPlane.Worker.Services;
|
||||
|
||||
public class KeycloakAdminClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _baseUrl;
|
||||
private readonly string _adminUser;
|
||||
private readonly string _adminPassword;
|
||||
private readonly ILogger<KeycloakAdminClient> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public KeycloakAdminClient(IConfiguration config, ILogger<KeycloakAdminClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_baseUrl = (config["Keycloak:AuthServerUrl"] ?? config["Keycloak:BaseUrl"])?.TrimEnd('/') ?? "http://localhost:8080";
|
||||
_adminUser = config["Keycloak:AdminUser"] ?? "admin";
|
||||
_adminPassword = config["Keycloak:AdminPassword"] ?? "admin";
|
||||
|
||||
var maskedPw = _adminPassword.Length > 2 ? $"{_adminPassword[0]}***{_adminPassword[^1]}" : "***";
|
||||
_logger.LogInformation("KeycloakAdminClient base URL: {Url}, user: {User}, password: {Password}",
|
||||
_baseUrl, _adminUser, maskedPw);
|
||||
|
||||
_http = new HttpClient { BaseAddress = new Uri(_baseUrl) };
|
||||
}
|
||||
|
||||
private async Task AuthorizeAsync(CancellationToken ct)
|
||||
{
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["client_id"] = "admin-cli",
|
||||
["username"] = _adminUser,
|
||||
["password"] = _adminPassword,
|
||||
});
|
||||
|
||||
var res = await _http.PostAsync("/realms/master/protocol/openid-connect/token", form, ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var token = doc.RootElement.GetProperty("access_token").GetString()!;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
|
||||
public async Task CreateRealmAsync(string realmId, string displayName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync("/admin/realms", Json(new
|
||||
{
|
||||
realm = realmId,
|
||||
displayName = displayName,
|
||||
enabled = true,
|
||||
registrationAllowed = true,
|
||||
registrationEmailAsUsername = false,
|
||||
loginWithEmailAllowed = true,
|
||||
resetPasswordAllowed = true,
|
||||
verifyEmail = false,
|
||||
sslRequired = "external",
|
||||
}), ct);
|
||||
|
||||
if (res.StatusCode == System.Net.HttpStatusCode.Conflict)
|
||||
{
|
||||
_logger.LogWarning("Realm {Realm} already exists - skipping.", realmId);
|
||||
return;
|
||||
}
|
||||
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Realm {Realm} created.", realmId);
|
||||
}
|
||||
|
||||
public async Task DeleteRealmAsync(string realmId, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.DeleteAsync($"/admin/realms/{realmId}", ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.NotFound)
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Realm {Realm} deleted.", realmId);
|
||||
}
|
||||
|
||||
public async Task CreateRealmRoleAsync(string realmId, string roleName, string description, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/roles",
|
||||
Json(new { name = roleName, description }), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<string> CreateUserAsync(string realmId, string email, string firstName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/users",
|
||||
Json(new { username = email, email, firstName, enabled = true, emailVerified = true }), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
return await GetUserIdAsync(realmId, email, ct);
|
||||
}
|
||||
|
||||
public async Task<string> GetUserIdAsync(string realmId, string email, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.GetAsync(
|
||||
$"/admin/realms/{realmId}/users?email={Uri.EscapeDataString(email)}&exact=true", ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var users = doc.RootElement.EnumerateArray().ToList();
|
||||
if (users.Count == 0)
|
||||
throw new InvalidOperationException($"User {email} not found in realm {realmId}.");
|
||||
return users[0].GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
public async Task AssignRealmRoleAsync(string realmId, string userId, string roleName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var roleRes = await _http.GetAsync($"/admin/realms/{realmId}/roles/{roleName}", ct);
|
||||
roleRes.EnsureSuccessStatusCode();
|
||||
var roleJson = await roleRes.Content.ReadAsStringAsync(ct);
|
||||
var res = await _http.PostAsync(
|
||||
$"/admin/realms/{realmId}/users/{userId}/role-mappings/realm",
|
||||
new StringContent($"[{roleJson}]", Encoding.UTF8, "application/json"), ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task CreateClientAsync(string realmId, object clientRepresentation, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync($"/admin/realms/{realmId}/clients",
|
||||
Json(clientRepresentation), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the internal Keycloak UUID for a client by its clientId string.
|
||||
/// </summary>
|
||||
public async Task<string> GetClientUuidAsync(string realmId, string clientId, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.GetAsync(
|
||||
$"/admin/realms/{realmId}/clients?clientId={Uri.EscapeDataString(clientId)}&search=false", ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync(ct));
|
||||
var clients = doc.RootElement.EnumerateArray().ToList();
|
||||
if (clients.Count == 0)
|
||||
throw new InvalidOperationException($"Client '{clientId}' not found in realm '{realmId}'.");
|
||||
return clients[0].GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an audience protocol mapper to a client so that the named audience is included in every
|
||||
/// access token issued by that client.
|
||||
/// </summary>
|
||||
public async Task AddAudienceMapperAsync(string realmId, string clientUuid, string audienceName, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PostAsync(
|
||||
$"/admin/realms/{realmId}/clients/{clientUuid}/protocol-mappers/models",
|
||||
Json(new
|
||||
{
|
||||
name = $"audience-{audienceName}",
|
||||
protocol = "openid-connect",
|
||||
protocolMapper = "oidc-audience-mapper",
|
||||
consentRequired = false,
|
||||
config = new Dictionary<string, string>
|
||||
{
|
||||
["included.client.audience"] = audienceName,
|
||||
["id.token.claim"] = "false",
|
||||
["access.token.claim"] = "true",
|
||||
},
|
||||
}), ct);
|
||||
if (res.StatusCode != System.Net.HttpStatusCode.Conflict)
|
||||
res.EnsureSuccessStatusCode();
|
||||
_logger.LogInformation("Added audience mapper '{Audience}' to client {ClientUuid} in realm {Realm}.",
|
||||
audienceName, clientUuid, realmId);
|
||||
}
|
||||
|
||||
public async Task SendRequiredActionsEmailAsync(
|
||||
string realmId, string userId, IEnumerable<string> actions, CancellationToken ct)
|
||||
{
|
||||
await AuthorizeAsync(ct);
|
||||
var res = await _http.PutAsync(
|
||||
$"/admin/realms/{realmId}/users/{userId}/execute-actions-email?lifespan=86400",
|
||||
new StringContent(JsonSerializer.Serialize(actions, JsonOpts), Encoding.UTF8, "application/json"),
|
||||
ct);
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static StringContent Json(object payload) =>
|
||||
new(JsonSerializer.Serialize(payload, JsonOpts), Encoding.UTF8, "application/json");
|
||||
}
|
||||
@@ -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