OPC # 0006: OPC Git Trunk-Based management
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -126,6 +126,40 @@ await using (var cmd = ds.CreateCommand("""
|
|||||||
CREATE INDEX IF NOT EXISTS ix_opc_artifact_opc_id ON opc_artifact(opc_id);
|
CREATE INDEX IF NOT EXISTS ix_opc_artifact_opc_id ON opc_artifact(opc_id);
|
||||||
CREATE INDEX IF NOT EXISTS ix_opc_artifact_type ON opc_artifact(opc_id, artifact_type);
|
CREATE INDEX IF NOT EXISTS ix_opc_artifact_type ON opc_artifact(opc_id, artifact_type);
|
||||||
CREATE INDEX IF NOT EXISTS ix_opc_pinned_commit_opc_id ON opc_pinned_commit(opc_id);
|
CREATE INDEX IF NOT EXISTS ix_opc_pinned_commit_opc_id ON opc_pinned_commit(opc_id);
|
||||||
|
|
||||||
|
-- ── Build + Release history ────────────────────────────────────────────
|
||||||
|
CREATE TABLE IF NOT EXISTS build_record (
|
||||||
|
id VARCHAR(8) PRIMARY KEY,
|
||||||
|
kind VARCHAR(20) NOT NULL,
|
||||||
|
target VARCHAR(500) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'Running',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
image_digest VARCHAR(200),
|
||||||
|
commit_sha VARCHAR(40),
|
||||||
|
log TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS release_record (
|
||||||
|
id VARCHAR(8) PRIMARY KEY,
|
||||||
|
environment VARCHAR(50) NOT NULL,
|
||||||
|
image_name VARCHAR(200) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'Running',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
finished_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS release_tenant_result (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
release_id VARCHAR(8) NOT NULL REFERENCES release_record(id) ON DELETE CASCADE,
|
||||||
|
subdomain VARCHAR(200) NOT NULL,
|
||||||
|
container_name VARCHAR(200) NOT NULL,
|
||||||
|
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_build_record_started_at ON build_record(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_build_record_kind ON build_record(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_release_record_started_at ON release_record(started_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_release_tenant_release_id ON release_tenant_result(release_id);
|
||||||
"""))
|
"""))
|
||||||
await cmd.ExecuteNonQueryAsync();
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
<PackageReference Include="Npgsql" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,46 +1,36 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using ControlPlane.Core.Models;
|
using ControlPlane.Core.Models;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
namespace ControlPlane.Core.Services;
|
namespace ControlPlane.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists build and release history to JSON files in the ClientAssets folder.
|
/// Persists build and release history to opcdb (Postgres).
|
||||||
/// Thread-safe — all writes go through a single lock per file.
|
/// Replaces the previous JSON-file implementation.
|
||||||
|
/// NpgsqlDataSource is singleton and manages the connection pool; this service is safe to register as singleton.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BuildHistoryService
|
public class BuildHistoryService(NpgsqlDataSource db, ILogger<BuildHistoryService> logger)
|
||||||
{
|
{
|
||||||
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 ──────────────────────────────────────────────────────────────
|
// ── Builds ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task<BuildRecord> CreateBuildAsync(BuildKind kind, string target)
|
public async Task<BuildRecord> CreateBuildAsync(BuildKind kind, string target)
|
||||||
{
|
{
|
||||||
var record = new BuildRecord { Kind = kind, Target = target };
|
var record = new BuildRecord { Kind = kind, Target = target };
|
||||||
await SaveBuildAsync(record);
|
|
||||||
|
await using var cmd = db.CreateCommand("""
|
||||||
|
INSERT INTO build_record (id, kind, target, status, started_at, commit_sha, log)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
""");
|
||||||
|
cmd.Parameters.AddWithValue(record.Id);
|
||||||
|
cmd.Parameters.AddWithValue(record.Kind.ToString());
|
||||||
|
cmd.Parameters.AddWithValue(record.Target);
|
||||||
|
cmd.Parameters.AddWithValue(record.Status.ToString());
|
||||||
|
cmd.Parameters.AddWithValue(record.StartedAt);
|
||||||
|
cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue(string.Empty);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,39 +40,68 @@ public class BuildHistoryService
|
|||||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||||
record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds;
|
record.DurationMs = (int)(record.FinishedAt.Value - record.StartedAt).TotalMilliseconds;
|
||||||
record.ImageDigest = digest;
|
record.ImageDigest = digest;
|
||||||
await SaveBuildAsync(record);
|
|
||||||
|
await using var cmd = db.CreateCommand("""
|
||||||
|
UPDATE build_record
|
||||||
|
SET status = $2, finished_at = $3, duration_ms = $4, image_digest = $5, commit_sha = $6, log = $7
|
||||||
|
WHERE id = $1
|
||||||
|
""");
|
||||||
|
cmd.Parameters.AddWithValue(record.Id);
|
||||||
|
cmd.Parameters.AddWithValue(record.Status.ToString());
|
||||||
|
cmd.Parameters.AddWithValue(record.FinishedAt!.Value);
|
||||||
|
cmd.Parameters.AddWithValue((object?)record.DurationMs ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue((object?)record.ImageDigest ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue(string.Join('\n', record.Log));
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AppendBuildLogAsync(BuildRecord record, string line)
|
public async Task AppendBuildLogAsync(BuildRecord record, string line)
|
||||||
{
|
{
|
||||||
record.Log.Add(line);
|
record.Log.Add(line);
|
||||||
// Flush to disk every 20 lines to avoid excessive I/O but keep reasonable freshness
|
// Flush to Postgres every 20 lines — keeps the live log queryable without hammering the DB
|
||||||
if (record.Log.Count % 20 == 0)
|
if (record.Log.Count % 20 == 0)
|
||||||
await SaveBuildAsync(record);
|
await FlushLogAsync(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FlushLogAsync(BuildRecord record)
|
||||||
|
{
|
||||||
|
await using var cmd = db.CreateCommand("UPDATE build_record SET log = $2 WHERE id = $1");
|
||||||
|
cmd.Parameters.AddWithValue(record.Id);
|
||||||
|
cmd.Parameters.AddWithValue(string.Join('\n', record.Log));
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<BuildRecord>> GetBuildsAsync()
|
public async Task<List<BuildRecord>> GetBuildsAsync()
|
||||||
{
|
{
|
||||||
await _buildLock.WaitAsync();
|
var result = new List<BuildRecord>();
|
||||||
try { return LoadJson<BuildRecord>(_buildsPath); }
|
|
||||||
finally { _buildLock.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveBuildAsync(BuildRecord record)
|
await using var cmd = db.CreateCommand("""
|
||||||
{
|
SELECT id, kind, target, status, started_at, finished_at, duration_ms, image_digest, commit_sha, log
|
||||||
await _buildLock.WaitAsync();
|
FROM build_record
|
||||||
try
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
""");
|
||||||
|
await using var reader = await cmd.ExecuteReaderAsync();
|
||||||
|
while (await reader.ReadAsync())
|
||||||
{
|
{
|
||||||
var all = LoadJson<BuildRecord>(_buildsPath);
|
var logText = reader.IsDBNull(9) ? "" : reader.GetString(9);
|
||||||
var idx = all.FindIndex(b => b.Id == record.Id);
|
result.Add(new BuildRecord
|
||||||
if (idx >= 0) all[idx] = record;
|
{
|
||||||
else all.Insert(0, record);
|
Id = reader.GetString(0),
|
||||||
|
Kind = Enum.Parse<BuildKind>(reader.GetString(1)),
|
||||||
// Keep last 100 builds
|
Target = reader.GetString(2),
|
||||||
if (all.Count > 100) all = all[..100];
|
Status = Enum.Parse<BuildStatus>(reader.GetString(3)),
|
||||||
await File.WriteAllTextAsync(_buildsPath, JsonSerializer.Serialize(all, JsonOpts));
|
StartedAt = reader.GetFieldValue<DateTimeOffset>(4),
|
||||||
|
FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
|
||||||
|
DurationMs = reader.IsDBNull(6) ? null : reader.GetInt32(6),
|
||||||
|
ImageDigest = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
CommitSha = reader.IsDBNull(8) ? null : reader.GetString(8),
|
||||||
|
Log = logText.Length == 0 ? [] : [.. logText.Split('\n')],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
finally { _buildLock.Release(); }
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Releases ────────────────────────────────────────────────────────────
|
// ── Releases ────────────────────────────────────────────────────────────
|
||||||
@@ -90,49 +109,111 @@ public class BuildHistoryService
|
|||||||
public async Task<ReleaseRecord> CreateReleaseAsync(string environment, string imageName)
|
public async Task<ReleaseRecord> CreateReleaseAsync(string environment, string imageName)
|
||||||
{
|
{
|
||||||
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
|
var record = new ReleaseRecord { Environment = environment, ImageName = imageName };
|
||||||
await SaveReleaseAsync(record);
|
|
||||||
|
await using var cmd = db.CreateCommand("""
|
||||||
|
INSERT INTO release_record (id, environment, image_name, status, started_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
""");
|
||||||
|
cmd.Parameters.AddWithValue(record.Id);
|
||||||
|
cmd.Parameters.AddWithValue(record.Environment);
|
||||||
|
cmd.Parameters.AddWithValue(record.ImageName);
|
||||||
|
cmd.Parameters.AddWithValue(record.Status.ToString());
|
||||||
|
cmd.Parameters.AddWithValue(record.StartedAt);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateReleaseAsync(ReleaseRecord record)
|
public async Task UpdateReleaseAsync(ReleaseRecord record)
|
||||||
{
|
{
|
||||||
record.FinishedAt = DateTimeOffset.UtcNow;
|
record.FinishedAt = DateTimeOffset.UtcNow;
|
||||||
await SaveReleaseAsync(record);
|
|
||||||
|
await using var conn = await db.OpenConnectionAsync();
|
||||||
|
await using var tx = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
|
await using var upd = new NpgsqlCommand("""
|
||||||
|
UPDATE release_record SET status = $2, finished_at = $3 WHERE id = $1
|
||||||
|
""", conn, tx);
|
||||||
|
upd.Parameters.AddWithValue(record.Id);
|
||||||
|
upd.Parameters.AddWithValue(record.Status.ToString());
|
||||||
|
upd.Parameters.AddWithValue(record.FinishedAt!.Value);
|
||||||
|
await upd.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
// Replace tenant results wholesale on each update
|
||||||
|
await using var del = new NpgsqlCommand(
|
||||||
|
"DELETE FROM release_tenant_result WHERE release_id = $1", conn, tx);
|
||||||
|
del.Parameters.AddWithValue(record.Id);
|
||||||
|
await del.ExecuteNonQueryAsync();
|
||||||
|
|
||||||
|
foreach (var t in record.Tenants)
|
||||||
|
{
|
||||||
|
await using var ins = new NpgsqlCommand("""
|
||||||
|
INSERT INTO release_tenant_result (release_id, subdomain, container_name, success, error)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
""", conn, tx);
|
||||||
|
ins.Parameters.AddWithValue(record.Id);
|
||||||
|
ins.Parameters.AddWithValue(t.Subdomain);
|
||||||
|
ins.Parameters.AddWithValue(t.ContainerName);
|
||||||
|
ins.Parameters.AddWithValue(t.Success);
|
||||||
|
ins.Parameters.AddWithValue((object?)t.Error ?? DBNull.Value);
|
||||||
|
await ins.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ReleaseRecord>> GetReleasesAsync()
|
public async Task<List<ReleaseRecord>> GetReleasesAsync()
|
||||||
{
|
{
|
||||||
await _releaseLock.WaitAsync();
|
var ordered = new List<ReleaseRecord>();
|
||||||
try { return LoadJson<ReleaseRecord>(_releasesPath); }
|
var lookup = new Dictionary<string, ReleaseRecord>();
|
||||||
finally { _releaseLock.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveReleaseAsync(ReleaseRecord record)
|
await using var cmd = db.CreateCommand("""
|
||||||
{
|
SELECT id, environment, image_name, status, started_at, finished_at
|
||||||
await _releaseLock.WaitAsync();
|
FROM release_record
|
||||||
try
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
""");
|
||||||
|
await using (var reader = await cmd.ExecuteReaderAsync())
|
||||||
{
|
{
|
||||||
var all = LoadJson<ReleaseRecord>(_releasesPath);
|
while (await reader.ReadAsync())
|
||||||
var idx = all.FindIndex(r => r.Id == record.Id);
|
{
|
||||||
if (idx >= 0) all[idx] = record;
|
var r = new ReleaseRecord
|
||||||
else all.Insert(0, record);
|
{
|
||||||
|
Id = reader.GetString(0),
|
||||||
if (all.Count > 50) all = all[..50];
|
Environment = reader.GetString(1),
|
||||||
await File.WriteAllTextAsync(_releasesPath, JsonSerializer.Serialize(all, JsonOpts));
|
ImageName = reader.GetString(2),
|
||||||
|
Status = Enum.Parse<ReleaseStatus>(reader.GetString(3)),
|
||||||
|
StartedAt = reader.GetFieldValue<DateTimeOffset>(4),
|
||||||
|
FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue<DateTimeOffset>(5),
|
||||||
|
};
|
||||||
|
ordered.Add(r);
|
||||||
|
lookup[r.Id] = r;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finally { _releaseLock.Release(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
if (lookup.Count == 0) return [];
|
||||||
|
|
||||||
private static List<T> LoadJson<T>(string path)
|
// Load all tenant results for the fetched release IDs in one query
|
||||||
{
|
await using var cmd2 = db.CreateCommand("""
|
||||||
if (!File.Exists(path)) return [];
|
SELECT release_id, subdomain, container_name, success, error
|
||||||
try
|
FROM release_tenant_result
|
||||||
|
WHERE release_id = ANY($1)
|
||||||
|
""");
|
||||||
|
cmd2.Parameters.Add(new NpgsqlParameter<string[]> { TypedValue = [.. lookup.Keys] });
|
||||||
|
await using var reader2 = await cmd2.ExecuteReaderAsync();
|
||||||
|
while (await reader2.ReadAsync())
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(path);
|
if (lookup.TryGetValue(reader2.GetString(0), out var r))
|
||||||
return JsonSerializer.Deserialize<List<T>>(json, JsonOpts) ?? [];
|
r.Tenants.Add(new TenantReleaseResult
|
||||||
|
{
|
||||||
|
Subdomain = reader2.GetString(1),
|
||||||
|
ContainerName = reader2.GetString(2),
|
||||||
|
Success = reader2.GetBoolean(3),
|
||||||
|
Error = reader2.IsDBNull(4) ? null : reader2.GetString(4),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch { return []; }
|
|
||||||
|
return ordered;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,18 +134,19 @@ export function triggerRelease(
|
|||||||
|
|
||||||
export interface ProjectDefinition {
|
export interface ProjectDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'DotnetProject' | 'NpmProject';
|
kind: 'DotnetProject' | 'NpmProject' | 'SolutionBuild';
|
||||||
relativePath: string;
|
relativePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildRecord {
|
export interface BuildRecord {
|
||||||
id: string;
|
id: string;
|
||||||
kind: 'DockerImage' | 'DotnetProject' | 'NpmProject';
|
kind: 'DockerImage' | 'DotnetProject' | 'NpmProject' | 'SolutionBuild';
|
||||||
target: string;
|
target: string;
|
||||||
status: 'Running' | 'Succeeded' | 'Failed';
|
status: 'Running' | 'Succeeded' | 'Failed';
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
finishedAt?: string;
|
finishedAt?: string;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
commitSha?: string;
|
||||||
log: string[];
|
log: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
const BASE_URL = import.meta.env.VITE_API_URL ?? '';
|
||||||
|
|
||||||
const KIND_INTENT: Record<string, Intent> = {
|
const KIND_INTENT: Record<string, Intent> = {
|
||||||
DotnetProject: Intent.PRIMARY,
|
DotnetProject: Intent.PRIMARY,
|
||||||
NpmProject: Intent.WARNING,
|
NpmProject: Intent.WARNING,
|
||||||
DockerImage: Intent.NONE,
|
DockerImage: Intent.NONE,
|
||||||
|
SolutionBuild: Intent.SUCCESS,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_INTENT: Record<string, Intent> = {
|
const STATUS_INTENT: Record<string, Intent> = {
|
||||||
|
|||||||
Reference in New Issue
Block a user