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,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");
}