OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user