using System.Text.Json; using ControlPlane.Core.Models; using Microsoft.Extensions.Logging; using Npgsql; namespace ControlPlane.Core.Services; /// /// Persists build and release history to opcdb (Postgres). /// Replaces the previous JSON-file implementation. /// NpgsqlDataSource is singleton and manages the connection pool; this service is safe to register as singleton. /// public class BuildHistoryService(NpgsqlDataSource db, ILogger logger) { // ── Builds ────────────────────────────────────────────────────────────── public async Task CreateBuildAsync(BuildKind kind, string target) { var record = new BuildRecord { Kind = kind, Target = target }; 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; } 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 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) { record.Log.Add(line); // Flush to Postgres every 20 lines — keeps the live log queryable without hammering the DB if (record.Log.Count % 20 == 0) 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> GetBuildsAsync() { var result = new List(); await using var cmd = db.CreateCommand(""" SELECT id, kind, target, status, started_at, finished_at, duration_ms, image_digest, commit_sha, log FROM build_record ORDER BY started_at DESC LIMIT 100 """); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var logText = reader.IsDBNull(9) ? "" : reader.GetString(9); result.Add(new BuildRecord { Id = reader.GetString(0), Kind = Enum.Parse(reader.GetString(1)), Target = reader.GetString(2), Status = Enum.Parse(reader.GetString(3)), StartedAt = reader.GetFieldValue(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(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')], }); } return result; } // ── Builds by SHA ──────────────────────────────────────────────────────── /// Returns all build records whose commit_sha exactly matches . public async Task> GetBuildsByShaAsync(string sha) { var result = new List(); await using var cmd = db.CreateCommand(""" SELECT id, kind, target, status, started_at, finished_at, duration_ms, image_digest, commit_sha, log FROM build_record WHERE commit_sha = $1 ORDER BY started_at DESC """); cmd.Parameters.AddWithValue(sha); await using var reader = await cmd.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var logText = reader.IsDBNull(9) ? "" : reader.GetString(9); result.Add(new BuildRecord { Id = reader.GetString(0), Kind = Enum.Parse(reader.GetString(1)), Target = reader.GetString(2), Status = Enum.Parse(reader.GetString(3)), StartedAt = reader.GetFieldValue(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(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')], }); } return result; } // ── Releases ──────────────────────────────────────────────────────────── public async Task CreateReleaseAsync(string environment, string imageName, string? commitSha = null) { var record = new ReleaseRecord { Environment = environment, ImageName = imageName, CommitSha = commitSha }; await using var cmd = db.CreateCommand(""" INSERT INTO release_record (id, environment, image_name, status, started_at, opc_numbers, commit_sha) VALUES ($1, $2, $3, $4, $5, $6, $7) """); 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); cmd.Parameters.Add(new NpgsqlParameter { TypedValue = [.. record.OpcNumbers] }); cmd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(); return record; } public async Task UpdateReleaseAsync(ReleaseRecord record) { record.FinishedAt = DateTimeOffset.UtcNow; 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, opc_numbers = $4, commit_sha = $5 WHERE id = $1 """, conn, tx); upd.Parameters.AddWithValue(record.Id); upd.Parameters.AddWithValue(record.Status.ToString()); upd.Parameters.AddWithValue(record.FinishedAt!.Value); upd.Parameters.Add(new NpgsqlParameter { TypedValue = [.. record.OpcNumbers] }); upd.Parameters.AddWithValue((object?)record.CommitSha ?? DBNull.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> GetReleasesAsync() { var ordered = new List(); var lookup = new Dictionary(); await using var cmd = db.CreateCommand(""" SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers, commit_sha FROM release_record ORDER BY started_at DESC LIMIT 50 """); await using (var reader = await cmd.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { var r = new ReleaseRecord { Id = reader.GetString(0), Environment = reader.GetString(1), ImageName = reader.GetString(2), Status = Enum.Parse(reader.GetString(3)), StartedAt = reader.GetFieldValue(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue(6)], CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7), }; ordered.Add(r); lookup[r.Id] = r; } } if (lookup.Count == 0) return []; // Load all tenant results for the fetched release IDs in one query await using var cmd2 = db.CreateCommand(""" SELECT release_id, subdomain, container_name, success, error FROM release_tenant_result WHERE release_id = ANY($1) """); cmd2.Parameters.Add(new NpgsqlParameter { TypedValue = [.. lookup.Keys] }); await using var reader2 = await cmd2.ExecuteReaderAsync(); while (await reader2.ReadAsync()) { if (lookup.TryGetValue(reader2.GetString(0), out var r)) 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), }); } return ordered; } /// /// Returns the most recent succeeded release for , or null if none exists. /// Used to calculate the OPC ticket delta between releases (previousSha..currentSha). /// public async Task GetLastSuccessfulReleaseForEnvAsync(string environment) { await using var cmd = db.CreateCommand(""" SELECT id, environment, image_name, status, started_at, finished_at, opc_numbers, commit_sha FROM release_record WHERE environment = $1 AND status = 'Succeeded' ORDER BY started_at DESC LIMIT 1 """); cmd.Parameters.AddWithValue(environment); await using var reader = await cmd.ExecuteReaderAsync(); if (!await reader.ReadAsync()) return null; return new ReleaseRecord { Id = reader.GetString(0), Environment = reader.GetString(1), ImageName = reader.GetString(2), Status = Enum.Parse(reader.GetString(3)), StartedAt = reader.GetFieldValue(4), FinishedAt = reader.IsDBNull(5) ? null : reader.GetFieldValue(5), OpcNumbers = reader.IsDBNull(6) ? [] : [.. reader.GetFieldValue(6)], CommitSha = reader.IsDBNull(7) ? null : reader.GetString(7), }; } }