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
@@ -0,0 +1,51 @@
namespace ControlPlane.Core.Config;
/// <summary>
/// Central configuration for all infrastructure URLs, network names, and domain values.
/// Bind from the "Clarity" section in appsettings.json or via AppHost environment variables.
/// Eliminates hardcoded strings spread across Worker, AppHost, and generated configs.
/// </summary>
public sealed class ClarityInfraOptions
{
public const string Section = "Clarity";
// ── Domain ────────────────────────────────────────────────────────────
/// <summary>The base DNS domain for all tenant subdomains. e.g. "clarity.test"</summary>
public string Domain { get; set; } = "clarity.test";
/// <summary>The Docker network all managed containers are attached to.</summary>
public string Network { get; set; } = "clarity-net";
// ── Keycloak ──────────────────────────────────────────────────────────
/// <summary>Public browser-facing Keycloak URL — used in redirect URIs and JWT iss claim.</summary>
public string KeycloakPublicUrl { get; set; } = "https://keycloak.clarity.test";
/// <summary>Internal Docker DNS URL for server-side Keycloak calls (avoids self-signed cert).</summary>
public string KeycloakInternalUrl { get; set; } = "http://keycloak:8080";
// ── Vault ─────────────────────────────────────────────────────────────
/// <summary>Internal Docker DNS URL for Vault — injected into tenant containers.</summary>
public string VaultInternalUrl { get; set; } = "http://vault:8200";
// ── nginx SSL certs ───────────────────────────────────────────────────
/// <summary>Path to the wildcard TLS cert inside the nginx container.</summary>
public string NginxCertPath { get; set; } = "/etc/nginx/certs/clarity.test.crt";
/// <summary>Path to the wildcard TLS key inside the nginx container.</summary>
public string NginxCertKeyPath { get; set; } = "/etc/nginx/certs/clarity.test.key";
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>Builds the public tenant URL for a given subdomain.</summary>
public string TenantPublicUrl(string subdomain) => $"https://{subdomain}.{Domain}";
/// <summary>Builds the public Keycloak realm URL for a given realm (browser-facing).</summary>
public string KeycloakRealmPublicUrl(string realm) => $"{KeycloakPublicUrl}/realms/{realm}";
/// <summary>Builds the internal Keycloak realm URL for a given realm (server-side).</summary>
public string KeycloakRealmInternalUrl(string realm) => $"{KeycloakInternalUrl}/realms/{realm}";
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,8 @@
namespace ControlPlane.Core.Interfaces;
public interface ISagaStep
{
string StepName { get; }
Task ExecuteAsync(SagaContext context, CancellationToken cancellationToken);
Task CompensateAsync(SagaContext context, CancellationToken cancellationToken);
}
@@ -0,0 +1,30 @@
using ControlPlane.Core.Models;
namespace ControlPlane.Core.Interfaces;
/// <summary>
/// Mutable context bag passed through every saga step.
/// Steps read inputs and write outputs here so downstream steps can consume them.
/// </summary>
public class SagaContext
{
public ProvisioningJob Job { get; init; } = default!;
// Written by DatabaseStep — connection string for the tenant's Postgres (shared or own)
public string? TenantConnectionString { get; set; }
public string? TenantStackName { get; set; }
// Written by KeycloakStep
public string? DayZeroUserSubjectId { get; set; }
public string? MagicLink { get; set; }
// Written by LaunchStep or PulumiStep — base URL for the provisioned tenant
public string? TenantApiBaseUrl { get; set; }
// Written by LaunchStep — primary app container name
public string? ContainerName { get; set; }
// Written by PulumiStep (DedicatedVM/Enterprise tier) — target host details for subsequent steps
public string? VmIpAddress { get; set; }
public string? VmSshKeyPath { get; set; }
}
@@ -0,0 +1,38 @@
using ControlPlane.Core.Models;
namespace ControlPlane.Core.Messages;
/// <summary>API -> Worker: kick off the saga.</summary>
public record ProvisionClientCommand
{
public Guid JobId { get; init; }
public string ClientName { get; init; } = string.Empty;
public string StateCode { get; init; } = string.Empty;
public string Subdomain { get; init; } = string.Empty;
public string AdminEmail { get; init; } = string.Empty;
public string SiteCode { get; init; } = string.Empty;
public string Environment { get; init; } = "fdev";
public TenantTier Tier { get; init; } = TenantTier.Shared;
}
/// <summary>Worker -> API/Gateway: one log event per saga step transition.</summary>
public record ProvisioningProgressEvent
{
public Guid JobId { get; init; }
public string Type { get; init; } = string.Empty; // step_started | step_complete | step_failed | job_complete | job_failed | diagnostic | compensation_started | compensation_complete
public string? Step { get; init; }
public string? Message { get; init; }
/// <summary>Full exception string (stack trace) for diagnostic events.</summary>
public string? Detail { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
/// <summary>Worker -> Gateway: published once when a job completes successfully. Triggers route registration.</summary>
public record TenantProvisionedEvent
{
public Guid JobId { get; init; }
public string Subdomain { get; init; } = string.Empty;
public TenantTier Tier { get; init; }
/// <summary>Base URL of the API instance for this tenant. For Shared/Isolated this is the shared API. For Dedicated it is the per-tenant instance.</summary>
public string ApiBaseUrl { get; init; } = string.Empty;
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ControlPlane.Core.Models;
public enum BuildStatus { Running, Succeeded, Failed }
public enum BuildKind { DockerImage, DotnetProject, NpmProject }
/// <summary>
/// Persisted record of a single build run — image build, dotnet build, or npm build.
/// Stored in ClientAssets/builds.json.
/// </summary>
public class BuildRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public BuildKind Kind { get; set; }
public string Target { get; set; } = string.Empty; // image name or project path
public BuildStatus Status { get; set; } = BuildStatus.Running;
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
public int? DurationMs { get; set; }
public string? ImageDigest { get; set; } // populated for DockerImage builds
public List<string> Log { get; set; } = [];
}
+26
View File
@@ -0,0 +1,26 @@
using System.Text.Json.Serialization;
namespace ControlPlane.Core.Models;
/// <summary>
/// Defines where a specific infrastructure component (Postgres, Keycloak, Vault, MinIO)
/// is hosted for a given tenant. Each component in a StackConfig is configured independently.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ComponentMode
{
/// <summary>Shared platform instance — logical slice only (realm, schema, bucket, namespace).</summary>
SharedPlatform,
/// <summary>Baked into the app image itself via supervisord. Trial tier only.</summary>
Bundled,
/// <summary>Own sidecar container on ControlPlane's shared Docker host.</summary>
OwnContainer,
/// <summary>Own VM with the component running inside Docker on it.</summary>
VpsDocker,
/// <summary>Own VM with the component running as a native OS process (no Docker).</summary>
VpsBareMetal
}
+10
View File
@@ -0,0 +1,10 @@
namespace ControlPlane.Core.Models;
public record GitCommit(
string Hash,
string ShortHash,
string Author,
string Date,
string Subject,
string[] Files
);
+63
View File
@@ -0,0 +1,63 @@
namespace ControlPlane.Core.Models;
// ── Repository ────────────────────────────────────────────────────────────────
public record GiteaRepo(
long Id,
string Name,
string FullName,
string DefaultBranch,
string CloneUrl,
string SshUrl,
bool Private
);
// ── Branch ────────────────────────────────────────────────────────────────────
public record GiteaBranch(
string Name,
string CommitSha,
bool Protected
);
// ── Pull Request ──────────────────────────────────────────────────────────────
public record GiteaPullRequest(
long Number,
string Title,
string State, // open | closed | merged
string HeadBranch,
string BaseBranch,
string HtmlUrl,
string CreatedAt,
string UpdatedAt,
GiteaUser? User,
GiteaMergeInfo? MergeInfo
);
public record GiteaUser(string Login, string AvatarUrl);
public record GiteaMergeInfo(bool Mergeable, bool Merged, string? MergedAt);
// ── Tag ───────────────────────────────────────────────────────────────────────
public record GiteaTag(string Name, string CommitSha, string ZipUrl);
// ── Webhook ───────────────────────────────────────────────────────────────────
public record GiteaWebhook(long Id, string Url, bool Active, string[] Events);
// ── Request shapes ────────────────────────────────────────────────────────────
public record CreateBranchRequest(string OpcNumber, string OpcTitle, string From = "master");
public record CreatePullRequestRequest(
string Title,
string Head,
string Base,
string Body
);
public record CreateTagRequest(string TagName, string Message, string CommitSha);
public record CreateWebhookRequest(string TargetUrl, string[] Events);
+73
View File
@@ -0,0 +1,73 @@
namespace ControlPlane.Core.Models;
public record OpcRecord(
Guid Id,
string Number,
string Title,
string Description,
string Type,
string Status,
string Priority,
string Assignee,
DateTime CreatedAt,
DateTime UpdatedAt
);
public record OpcNote(
Guid Id,
Guid OpcId,
string Author,
string Content,
DateTime CreatedAt
);
public record OpcArtifact(
Guid Id,
Guid OpcId,
string ArtifactType,
string Title,
string Content,
DateTime CreatedAt,
DateTime UpdatedAt
);
// Request / response shapes used by the API endpoints
public record CreateOpcRequest(
string Title,
string Type,
string Priority,
string Assignee,
string Description
);
public record UpdateOpcRequest(
string? Title,
string? Description,
string? Type,
string? Status,
string? Priority,
string? Assignee
);
public record AddNoteRequest(string Author, string Content);
public record UpsertArtifactRequest(
string ArtifactType,
string Title,
string Content
);
public record AiAssistRequest(string Prompt, string? Context);
public record OpcPinnedCommit(
Guid OpcId,
string Hash,
string ShortHash,
string Subject,
string Author,
DateTime PinnedAt,
string PinnedBy
);
public record PinCommitRequest(string Hash, string PinnedBy);
@@ -0,0 +1,22 @@
namespace ControlPlane.Core.Models;
public enum PromotionStatus { Pending, Running, Succeeded, Failed }
/// <summary>
/// Represents a request to promote (merge) one environment branch into the next.
/// e.g. develop → staging, staging → uat, uat → main
/// </summary>
public class PromotionRequest
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string FromBranch { get; set; } = string.Empty;
public string ToBranch { get; set; } = string.Empty;
public string RequestedBy { get; set; } = "system";
public string? Note { get; set; }
public PromotionStatus Status { get; set; } = PromotionStatus.Pending;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAt { get; set; }
public List<string> Log { get; set; } = [];
public int CommitCount { get; set; } // commits in from that are not in to
public string[] CommitLines { get; set; } = []; // oneline summary of those commits
}
@@ -0,0 +1,47 @@
namespace ControlPlane.Core.Models;
public enum ProvisioningStatus
{
Pending,
Running,
Compensating,
Failed,
Completed
}
[Flags]
public enum CompletedSteps
{
None = 0,
InfrastructureProvisioned = 1 << 0,
KeycloakProvisioned = 1 << 1,
VaultVerified = 1 << 2,
DatabaseMigrated = 1 << 3,
HandoffSent = 1 << 4
}
public class ProvisioningJob
{
public Guid Id { get; set; } = Guid.NewGuid();
public string ClientName { get; set; } = string.Empty;
public string StateCode { get; set; } = string.Empty;
public string Subdomain { get; set; } = string.Empty;
public string AdminEmail { get; set; } = string.Empty;
public string SiteCode { get; set; } = string.Empty;
public string Environment { get; set; } = "fdev";
public TenantTier Tier { get; set; } = TenantTier.Shared;
/// <summary>
/// Snapshot of the StackConfig at the time provisioning was requested.
/// Immutable after the job is created.
/// </summary>
public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared);
public ProvisioningStatus Status { get; set; } = ProvisioningStatus.Pending;
public CompletedSteps CompletedSteps { get; set; } = CompletedSteps.None;
public string? FailureReason { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? CompletedAt { get; set; }
}
@@ -0,0 +1,18 @@
namespace ControlPlane.Core.Models;
public class ProvisioningRequest
{
public string ClientName { get; set; } = string.Empty;
public string StateCode { get; set; } = string.Empty;
public string Subdomain { get; set; } = string.Empty;
public string AdminEmail { get; set; } = string.Empty;
public string SiteCode { get; set; } = string.Empty;
public string Environment { get; set; } = "fdev";
public TenantTier Tier { get; set; } = TenantTier.Shared;
/// <summary>
/// Per-component infrastructure configuration. Defaults to the standard profile
/// for the selected tier if not explicitly specified.
/// </summary>
public StackConfig StackConfig { get; set; } = StackConfig.DefaultForTier(TenantTier.Shared);
}
+27
View File
@@ -0,0 +1,27 @@
namespace ControlPlane.Core.Models;
public enum ReleaseStatus { Running, Succeeded, PartialFailure, Failed }
/// <summary>
/// Persisted record of a release — a coordinated redeploy of all tenant containers
/// in a target environment to the latest clarity-server image.
/// Stored in ClientAssets/releases.json.
/// </summary>
public class ReleaseRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8];
public string Environment { get; set; } = string.Empty; // fdev | uat | prod | all
public string ImageName { get; set; } = string.Empty;
public ReleaseStatus Status { get; set; } = ReleaseStatus.Running;
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
public List<TenantReleaseResult> Tenants { get; set; } = [];
}
public class TenantReleaseResult
{
public string Subdomain { get; set; } = string.Empty;
public string ContainerName { get; set; } = string.Empty;
public bool Success { get; set; }
public string? Error { get; set; }
}
+51
View File
@@ -0,0 +1,51 @@
namespace ControlPlane.Core.Models;
/// <summary>
/// Defines the exact infrastructure composition for a provisioned tenant.
/// Each component is configured independently — the TenantTier gates which
/// ComponentMode values are available in the UI.
///
/// Allowed modes per tier:
///
/// | Trial | Shared | Dedicated | Enterprise |
/// SharedPlatform | ✅ | ✅ | ✅ | ✅ |
/// Bundled | ✅ | ❌ | ❌ | ❌ |
/// OwnContainer | ❌ | ❌ | ✅ | ✅ |
/// VpsDocker | ❌ | ❌ | ❌ | ✅ |
/// VpsBareMetal | ❌ | ❌ | ❌ | ✅ |
/// </summary>
public class StackConfig
{
public ComponentMode Postgres { get; set; } = ComponentMode.SharedPlatform;
public ComponentMode Keycloak { get; set; } = ComponentMode.SharedPlatform;
public ComponentMode Vault { get; set; } = ComponentMode.SharedPlatform;
public ComponentMode Minio { get; set; } = ComponentMode.SharedPlatform;
/// <summary>Returns a default StackConfig for the given tier.</summary>
public static StackConfig DefaultForTier(TenantTier tier) => tier switch
{
TenantTier.Trial => new StackConfig
{
Postgres = ComponentMode.Bundled,
Keycloak = ComponentMode.SharedPlatform,
Vault = ComponentMode.SharedPlatform,
Minio = ComponentMode.SharedPlatform
},
TenantTier.Shared => new StackConfig(),
TenantTier.Dedicated => new StackConfig
{
Postgres = ComponentMode.OwnContainer,
Keycloak = ComponentMode.OwnContainer,
Vault = ComponentMode.OwnContainer,
Minio = ComponentMode.OwnContainer
},
TenantTier.Enterprise => new StackConfig
{
Postgres = ComponentMode.VpsDocker,
Keycloak = ComponentMode.VpsDocker,
Vault = ComponentMode.VpsDocker,
Minio = ComponentMode.VpsDocker
},
_ => new StackConfig()
};
}
+135
View File
@@ -0,0 +1,135 @@
using System.Xml.Serialization;
namespace ControlPlane.Core.Models;
[XmlRoot("Tenant")]
public class TenantRecord
{
// ── Identity ──────────────────────────────────────────────────────────
[XmlAttribute]
public string Subdomain { get; set; } = string.Empty;
[XmlElement]
public string ClientName { get; set; } = string.Empty;
[XmlElement]
public string StateCode { get; set; } = string.Empty;
[XmlElement]
public string AdminEmail { get; set; } = string.Empty;
[XmlElement]
public string SiteCode { get; set; } = string.Empty;
[XmlElement]
public string Environment { get; set; } = "fdev";
[XmlElement]
public string Tier { get; set; } = string.Empty;
[XmlElement]
public string Status { get; set; } = "Provisioning";
[XmlElement]
public string ProvisionedAt { get; set; } = DateTimeOffset.UtcNow.ToString("o");
[XmlElement]
public string JobId { get; set; } = string.Empty;
// ── Container (written by InfrastructureStep / LaunchStep) ────────────
[XmlElement(IsNullable = true)]
public string? ContainerName { get; set; }
[XmlElement(IsNullable = true)]
public string? ContainerPort { get; set; }
[XmlElement(IsNullable = true)]
public string? ContainerImage { get; set; }
[XmlElement(IsNullable = true)]
public string? ContainerNetwork { get; set; }
[XmlElement(IsNullable = true)]
public string? NginxConfPath { get; set; }
[XmlElement(IsNullable = true)]
public string? ApiBaseUrl { get; set; }
[XmlElement(IsNullable = true)]
public string? PublicUrl { get; set; }
[XmlElement(IsNullable = true)]
public string? LastProvisioningStep { get; set; }
[XmlElement(IsNullable = true)]
public string? ProvisioningNotes { get; set; }
// ── web.config-style sections ─────────────────────────────────────────
[XmlElement("ConnectionStrings")]
public ConnectionStringsSection ConnectionStrings { get; set; } = new();
[XmlElement("AppSettings")]
public AppSettingsSection AppSettings { get; set; } = new();
// ── Helpers ───────────────────────────────────────────────────────────
public void SetConnectionString(string name, string connectionString)
{
var existing = ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name);
if (existing is not null)
existing.ConnectionString = connectionString;
else
ConnectionStrings.Entries.Add(new ConnectionStringEntry { Name = name, ConnectionString = connectionString });
}
public string? GetConnectionString(string name) =>
ConnectionStrings.Entries.FirstOrDefault(e => e.Name == name)?.ConnectionString;
public void SetAppSetting(string key, string value)
{
var existing = AppSettings.Entries.FirstOrDefault(e => e.Key == key);
if (existing is not null)
existing.Value = value;
else
AppSettings.Entries.Add(new AppSettingEntry { Key = key, Value = value });
}
public string? GetAppSetting(string key) =>
AppSettings.Entries.FirstOrDefault(e => e.Key == key)?.Value;
}
// ── Section types ──────────────────────────────────────────────────────────
public class ConnectionStringsSection
{
[XmlElement("add")]
public List<ConnectionStringEntry> Entries { get; set; } = [];
}
public class AppSettingsSection
{
[XmlElement("add")]
public List<AppSettingEntry> Entries { get; set; } = [];
}
public class ConnectionStringEntry
{
[XmlAttribute("name")]
public string Name { get; set; } = string.Empty;
[XmlAttribute("connectionString")]
public string ConnectionString { get; set; } = string.Empty;
[XmlAttribute("providerName")]
public string ProviderName { get; set; } = "System.Data.SqlClient";
}
public class AppSettingEntry
{
[XmlAttribute("key")]
public string Key { get; set; } = string.Empty;
[XmlAttribute("value")]
public string Value { get; set; } = string.Empty;
}
+21
View File
@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace ControlPlane.Core.Models;
/// <summary>
/// Defines the billing and support level for a provisioned tenant.
/// The tier gates which ComponentMode values are available per component in the StackConfig.
///
/// Trial - ephemeral sandbox, all-in-one image, no persistent data guarantee.
/// Shared - real production data, shared platform infrastructure (logical slices only).
/// Dedicated - full container isolation per component, still on ControlPlane's shared host.
/// Enterprise - full VM isolation per component (VpsDocker or VpsBareMetal), Pulumi provisioned.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TenantTier
{
Trial,
Shared,
Dedicated,
Enterprise
}
@@ -0,0 +1,138 @@
using System.Text.Json;
using ControlPlane.Core.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ControlPlane.Core.Services;
/// <summary>
/// Persists build and release history to JSON files in the ClientAssets folder.
/// Thread-safe — all writes go through a single lock per file.
/// </summary>
public class BuildHistoryService
{
private readonly string _buildsPath;
private readonly string _releasesPath;
private readonly ILogger<BuildHistoryService> _logger;
private static readonly SemaphoreSlim _buildLock = new(1, 1);
private static readonly SemaphoreSlim _releaseLock = new(1, 1);
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
public BuildHistoryService(IConfiguration config, ILogger<BuildHistoryService> logger)
{
var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
Directory.CreateDirectory(folder);
_buildsPath = Path.Combine(folder, "builds.json");
_releasesPath = Path.Combine(folder, "releases.json");
_logger = logger;
}
// ── Builds ──────────────────────────────────────────────────────────────
public async Task<BuildRecord> CreateBuildAsync(BuildKind kind, string target)
{
var record = new BuildRecord { Kind = kind, Target = target };
await SaveBuildAsync(record);
return record;
}
public async Task CompleteBuildAsync(BuildRecord record, BuildStatus status, string? digest = null)
{
record.Status = status;
record.FinishedAt = DateTimeOffset.UtcNow;
record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds;
record.ImageDigest = digest;
await SaveBuildAsync(record);
}
public async Task AppendBuildLogAsync(BuildRecord record, string line)
{
record.Log.Add(line);
// Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness
if (record.Log.Count % 20 == 0)
await SaveBuildAsync(record);
}
public async Task<List<BuildRecord>> GetBuildsAsync()
{
await _buildLock.WaitAsync();
try { return LoadJson<BuildRecord>(_buildsPath); }
finally { _buildLock.Release(); }
}
private async Task SaveBuildAsync(BuildRecord record)
{
await _buildLock.WaitAsync();
try
{
var all = LoadJson<BuildRecord>(_buildsPath);
var idx = all.FindIndex(b => b.Id == record.Id);
if (idx >= 0) all[idx] = record;
else all.Insert(0, record);
// Keep last 100 builds
if (all.Count > 100) all = all[..100];
await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts));
}
finally { _buildLock.Release(); }
}
// ── Releases ────────────────────────────────────────────────────────────
public async Task<ReleaseRecord> CreateReleaseAsync(string environment, string imageName)
{
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
await SaveReleaseAsync(record);
return record;
}
public async Task UpdateReleaseAsync(ReleaseRecord record)
{
record.FinishedAt = DateTimeOffset.UtcNow;
await SaveReleaseAsync(record);
}
public async Task<List<ReleaseRecord>> GetReleasesAsync()
{
await _releaseLock.WaitAsync();
try { return LoadJson<ReleaseRecord>(_releasesPath); }
finally { _releaseLock.Release(); }
}
private async Task SaveReleaseAsync(ReleaseRecord record)
{
await _releaseLock.WaitAsync();
try
{
var all = LoadJson<ReleaseRecord>(_releasesPath);
var idx = all.FindIndex(r => r.Id == record.Id);
if (idx >= 0) all[idx] = record;
else all.Insert(0, record);
if (all.Count > 50) all = all[..50];
await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts));
}
finally { _releaseLock.Release(); }
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static List<T> LoadJson<T>(string path)
{
if (!File.Exists(path)) return [];
try
{
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<T>>(json, JsonOpts) ?? [];
}
catch { return []; }
}
}
@@ -0,0 +1,77 @@
using System.Xml.Serialization;
using ControlPlane.Core.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ControlPlane.Core.Services;
/// <summary>
/// Reads and writes per-tenant XML config files under the ClientAssets folder.
/// One file per tenant: {subdomain}.xml
/// Thread-safe for concurrent reads; writes are serialized per subdomain via per-file locking.
/// </summary>
public class TenantRegistryService
{
private readonly string _folder;
private readonly ILogger<TenantRegistryService> _logger;
private static readonly XmlSerializer Serializer = new(typeof(TenantRecord));
// One lock object per subdomain so writes to different tenants never block each other
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, object> _locks = new(StringComparer.OrdinalIgnoreCase);
public TenantRegistryService(IConfiguration configuration, ILogger<TenantRegistryService> logger)
{
_folder = configuration["ClientAssets__Folder"] ?? configuration["ClientAssets:Folder"]
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
_logger = logger;
Directory.CreateDirectory(_folder);
}
// -- Write --
public void Save(TenantRecord record)
{
var path = FilePath(record.Subdomain);
var gate = _locks.GetOrAdd(record.Subdomain, _ => new object());
lock (gate)
{
using var writer = new StreamWriter(path, append: false, System.Text.Encoding.UTF8);
Serializer.Serialize(writer, record);
}
_logger.LogInformation("Saved tenant record: {Path}", path);
}
// -- Read --
public TenantRecord? TryGet(string subdomain)
{
var path = FilePath(subdomain);
if (!File.Exists(path)) return null;
using var reader = new StreamReader(path, System.Text.Encoding.UTF8);
return (TenantRecord?)Serializer.Deserialize(reader);
}
public IReadOnlyList<TenantRecord> GetAll()
{
var results = new List<TenantRecord>();
foreach (var file in Directory.EnumerateFiles(_folder, "*.xml"))
{
try
{
using var reader = new StreamReader(file, System.Text.Encoding.UTF8);
if (Serializer.Deserialize(reader) is TenantRecord record)
results.Add(record);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipping malformed tenant file: {File}", file);
}
}
return results;
}
public bool Exists(string subdomain) => File.Exists(FilePath(subdomain));
private string FilePath(string subdomain) =>
Path.Combine(_folder, $"{subdomain.ToLowerInvariant()}.xml");
}