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
+216
View File
@@ -0,0 +1,216 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ControlPlane.Core.Models;
namespace ControlPlane.Api.Services;
/// <summary>
/// Thin wrapper around the Gitea REST API v1.
/// Configured via Gitea__BaseUrl, Gitea__Owner, and Gitea__Token in appsettings.
/// </summary>
public class GiteaService
{
private readonly HttpClient _http;
private readonly string _owner;
private readonly string _repo;
private readonly ILogger<GiteaService> _log;
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public GiteaService(IHttpClientFactory factory, IConfiguration cfg, ILogger<GiteaService> log)
{
_log = log;
_owner = cfg["Gitea:Owner"] ?? "Clarity";
_repo = cfg["Gitea:Repo"] ?? "Clarity";
var baseUrl = cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test";
var token = cfg["Gitea:Token"] ?? string.Empty;
_http = factory.CreateClient("gitea");
_http.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/api/v1/");
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (!string.IsNullOrWhiteSpace(token))
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token);
}
// ── Repos ─────────────────────────────────────────────────────────────────
public async Task<GiteaRepo?> GetRepoAsync(CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<GiteaRepo>($"repos/{_owner}/{_repo}", JsonOpts, ct);
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetRepo failed"); return null; }
}
// ── Branches ──────────────────────────────────────────────────────────────
public async Task<List<GiteaBranch>> ListBranchesAsync(CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<List<GiteaBranch>>(
$"repos/{_owner}/{_repo}/branches?limit=50", JsonOpts, ct) ?? [];
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListBranches failed"); return []; }
}
public async Task<GiteaBranch?> CreateBranchAsync(CreateBranchRequest req, CancellationToken ct = default)
{
// Slugify: "OPC # 0032" + title → "feature/OPC-0032-git-workflow-integration"
var slug = SlugifyTitle(req.OpcTitle);
var num = req.OpcNumber.Replace("OPC # ", "OPC-").Replace(" ", "");
var branchName = $"feature/{num}-{slug}";
var body = JsonSerializer.Serialize(new
{
new_branch_name = branchName,
old_branch_name = req.From,
}, JsonOpts);
var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/branches",
new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync(ct);
_log.LogWarning("Gitea CreateBranch failed {Status}: {Error}", res.StatusCode, err);
return null;
}
return await res.Content.ReadFromJsonAsync<GiteaBranch>(JsonOpts, ct);
}
// ── Pull Requests ─────────────────────────────────────────────────────────
public async Task<List<GiteaPullRequest>> ListPullRequestsAsync(
string state = "open", CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<List<GiteaPullRequest>>(
$"repos/{_owner}/{_repo}/pulls?state={state}&limit=50", JsonOpts, ct) ?? [];
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListPRs failed"); return []; }
}
public async Task<GiteaPullRequest?> GetPullRequestAsync(long number, CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<GiteaPullRequest>(
$"repos/{_owner}/{_repo}/pulls/{number}", JsonOpts, ct);
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea GetPR failed"); return null; }
}
public async Task<GiteaPullRequest?> CreatePullRequestAsync(
CreatePullRequestRequest req, CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new
{
title = req.Title,
head = req.Head,
@base = req.Base,
body = req.Body,
}, JsonOpts);
var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/pulls",
new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync(ct);
_log.LogWarning("Gitea CreatePR failed {Status}: {Error}", res.StatusCode, err);
return null;
}
return await res.Content.ReadFromJsonAsync<GiteaPullRequest>(JsonOpts, ct);
}
// ── Tags ──────────────────────────────────────────────────────────────────
public async Task<List<GiteaTag>> ListTagsAsync(CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<List<GiteaTag>>(
$"repos/{_owner}/{_repo}/tags?limit=20", JsonOpts, ct) ?? [];
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListTags failed"); return []; }
}
public async Task<GiteaTag?> CreateTagAsync(CreateTagRequest req, CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new
{
tag_name = req.TagName,
message = req.Message,
target = req.CommitSha,
}, JsonOpts);
var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/tags",
new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync(ct);
_log.LogWarning("Gitea CreateTag failed {Status}: {Error}", res.StatusCode, err);
return null;
}
return await res.Content.ReadFromJsonAsync<GiteaTag>(JsonOpts, ct);
}
// ── Webhooks ──────────────────────────────────────────────────────────────
public async Task<List<GiteaWebhook>> ListWebhooksAsync(CancellationToken ct = default)
{
try
{
return await _http.GetFromJsonAsync<List<GiteaWebhook>>(
$"repos/{_owner}/{_repo}/hooks", JsonOpts, ct) ?? [];
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea ListWebhooks failed"); return []; }
}
public async Task<GiteaWebhook?> RegisterWebhookAsync(
CreateWebhookRequest req, CancellationToken ct = default)
{
var body = JsonSerializer.Serialize(new
{
type = "gitea",
active = true,
config = new { url = req.TargetUrl, content_type = "json" },
events = req.Events,
}, JsonOpts);
var res = await _http.PostAsync(
$"repos/{_owner}/{_repo}/hooks",
new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync(ct);
_log.LogWarning("Gitea RegisterWebhook failed {Status}: {Error}", res.StatusCode, err);
return null;
}
return await res.Content.ReadFromJsonAsync<GiteaWebhook>(JsonOpts, ct);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string SlugifyTitle(string title) =>
System.Text.RegularExpressions.Regex
.Replace(title.ToLowerInvariant(), @"[^a-z0-9]+", "-")
.Trim('-')[..Math.Min(40, title.Length)];
}
@@ -0,0 +1,144 @@
using ControlPlane.Core.Models;
using ControlPlane.Core.Services;
using Docker.DotNet;
using Docker.DotNet.Models;
namespace ControlPlane.Api.Services;
/// <summary>
/// Drives `docker build` for the clarity-server image via the Docker SDK.
/// Streams each build log line to the provided callback so the API endpoint
/// can forward it as SSE to the control plane UI in real time.
/// Persists build history via BuildHistoryService.
/// </summary>
public class ImageBuildService(
IConfiguration config,
BuildHistoryService history,
ILogger<ImageBuildService> logger)
{
private static readonly SemaphoreSlim _lock = new(1, 1);
public bool IsBuilding => _lock.CurrentCount == 0;
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
public async Task<ImageBuildStatus> GetStatusAsync()
{
var builds = await history.GetBuildsAsync();
var last = builds.FirstOrDefault(b => b.Kind == BuildKind.DockerImage);
return new ImageBuildStatus(
last?.Target,
last?.FinishedAt,
last?.Status.ToString() ?? "Never built",
IsBuilding);
}
/// <summary>
/// Runs docker build and streams each log line to <paramref name="onLine"/>.
/// Returns true on success, false if the build failed or was already running.
/// </summary>
public async Task<bool> BuildAsync(
string repoRoot,
Action<string> onLine,
CancellationToken ct)
{
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
{
onLine("⚠️ A build is already in progress.");
return false;
}
var record = await history.CreateBuildAsync(BuildKind.DockerImage, ImageName);
try
{
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
var (repo, tag) = SplitImageTag(ImageName);
var dockerfilePath = "Clarity.Server/Dockerfile";
void Log(string line) { onLine(line); record.Log.Add(line); }
Log($"▶ Building {ImageName} from {repoRoot}");
Log($" Dockerfile: {dockerfilePath}");
Log("──────────────────────────────────────");
var buildParams = new ImageBuildParameters
{
Dockerfile = dockerfilePath,
Tags = [$"{repo}:{tag}"],
Remove = true,
ForceRemove = true,
};
bool success = true;
string? errorDetail = null;
await docker.Images.BuildImageFromDockerfileAsync(
buildParams,
await CreateTarballAsync(repoRoot, ct),
authConfigs: null,
headers: null,
new Progress<JSONMessage>(msg =>
{
if (!string.IsNullOrWhiteSpace(msg.Stream))
Log(msg.Stream.TrimEnd('\n'));
if (msg.Error is not null)
{
success = false;
errorDetail = msg.Error.Message;
Log($"✖ {msg.Error.Message}");
}
}),
ct);
Log("──────────────────────────────────────");
if (success) Log($"✔ {ImageName} built successfully at {DateTimeOffset.UtcNow:u}");
else Log($"✖ Build failed: {errorDetail}");
await history.CompleteBuildAsync(record, success ? BuildStatus.Succeeded : BuildStatus.Failed);
logger.LogInformation("Image build {Result} for {Image}", success ? "succeeded" : "failed", ImageName);
return success;
}
catch (Exception ex)
{
record.Log.Add($"Exception: {ex.Message}");
await history.CompleteBuildAsync(record, BuildStatus.Failed);
onLine($"✖ Exception during build: {ex.Message}");
logger.LogError(ex, "Image build threw an exception.");
return false;
}
finally
{
_lock.Release();
}
}
/// <summary>
/// Packs the entire repo root into a tar stream for the Docker build context.
/// Respects .dockerignore if present.
/// </summary>
private static async Task<Stream> CreateTarballAsync(string repoRoot, CancellationToken ct)
{
// Use docker's own CLI to create the tarball via stdin — avoids reimplementing
// .dockerignore parsing. Fall back to a pure managed tar if CLI isn't available.
// For simplicity we use a managed approach: stream the directory as a tar.
var ms = new MemoryStream();
await Task.Run(() => TarHelper.Pack(repoRoot, ms), ct);
ms.Position = 0;
return ms;
}
private static (string repo, string tag) SplitImageTag(string image)
{
var colon = image.LastIndexOf(':');
return colon < 0 ? (image, "latest") : (image[..colon], image[(colon + 1)..]);
}
}
public record ImageBuildStatus(
string? ImageName,
DateTimeOffset? BuiltAt,
string LastMessage,
bool IsBuilding);
+297
View File
@@ -0,0 +1,297 @@
using ControlPlane.Core.Models;
using Npgsql;
namespace ControlPlane.Api.Services;
public class OpcService(NpgsqlDataSource db)
{
// ── Helpers ──────────────────────────────────────────────────────────────
private static OpcRecord ReadOpc(NpgsqlDataReader r) => new(
r.GetGuid(0),
r.GetString(1),
r.GetString(2),
r.GetString(3),
r.GetString(4),
r.GetString(5),
r.GetString(6),
r.GetString(7),
r.GetDateTime(8),
r.GetDateTime(9)
);
private static OpcNote ReadNote(NpgsqlDataReader r) => new(
r.GetGuid(0),
r.GetGuid(1),
r.GetString(2),
r.GetString(3),
r.GetDateTime(4)
);
private static OpcArtifact ReadArtifact(NpgsqlDataReader r) => new(
r.GetGuid(0),
r.GetGuid(1),
r.GetString(2),
r.GetString(3),
r.GetString(4),
r.GetDateTime(5),
r.GetDateTime(6)
);
// ── Next OPC number ───────────────────────────────────────────────────────
public async Task<string> NextNumberAsync(CancellationToken ct = default)
{
await using var cmd = db.CreateCommand(
"SELECT number FROM opc ORDER BY CAST(TRIM(SUBSTRING(number FROM 7)) AS INTEGER) DESC LIMIT 1");
var last = await cmd.ExecuteScalarAsync(ct) as string;
if (last is null) return "OPC # 0001";
if (int.TryParse(last[6..], out var n))
return $"OPC # {n + 1:D4}";
return "OPC # 0001";
}
// ── OPC CRUD ──────────────────────────────────────────────────────────────
public async Task<List<OpcRecord>> ListAsync(
string? typeFilter = null, string? statusFilter = null,
CancellationToken ct = default)
{
var sql = """
SELECT id, number, title, description, type, status, priority, assignee,
created_at, updated_at
FROM opc
WHERE ($1::text IS NULL OR type = $1)
AND ($2::text IS NULL OR status = $2)
ORDER BY created_at DESC
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(typeFilter ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(statusFilter ?? (object)DBNull.Value);
await using var r = await cmd.ExecuteReaderAsync(ct);
var list = new List<OpcRecord>();
while (await r.ReadAsync(ct)) list.Add(ReadOpc(r));
return list;
}
public async Task<OpcRecord?> GetAsync(Guid id, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand(
"SELECT id, number, title, description, type, status, priority, assignee, created_at, updated_at FROM opc WHERE id = $1");
cmd.Parameters.AddWithValue(id);
await using var r = await cmd.ExecuteReaderAsync(ct);
return await r.ReadAsync(ct) ? ReadOpc(r) : null;
}
public async Task<OpcRecord> CreateAsync(CreateOpcRequest req, CancellationToken ct = default)
{
var number = await NextNumberAsync(ct);
var sql = """
INSERT INTO opc (number, title, description, type, status, priority, assignee)
VALUES ($1, $2, $3, $4, 'New', $5, $6)
RETURNING id, number, title, description, type, status, priority, assignee,
created_at, updated_at
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(number);
cmd.Parameters.AddWithValue(req.Title);
cmd.Parameters.AddWithValue(req.Description);
cmd.Parameters.AddWithValue(req.Type);
cmd.Parameters.AddWithValue(req.Priority);
cmd.Parameters.AddWithValue(req.Assignee);
await using var r = await cmd.ExecuteReaderAsync(ct);
await r.ReadAsync(ct);
return ReadOpc(r);
}
public async Task<OpcRecord?> UpdateAsync(Guid id, UpdateOpcRequest req, CancellationToken ct = default)
{
var sql = """
UPDATE opc SET
title = COALESCE($2, title),
description = COALESCE($3, description),
type = COALESCE($4, type),
status = COALESCE($5, status),
priority = COALESCE($6, priority),
assignee = COALESCE($7, assignee),
updated_at = NOW()
WHERE id = $1
RETURNING id, number, title, description, type, status, priority, assignee,
created_at, updated_at
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(id);
cmd.Parameters.AddWithValue(req.Title ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(req.Description ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(req.Type ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(req.Status ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(req.Priority ?? (object)DBNull.Value);
cmd.Parameters.AddWithValue(req.Assignee ?? (object)DBNull.Value);
await using var r = await cmd.ExecuteReaderAsync(ct);
return await r.ReadAsync(ct) ? ReadOpc(r) : null;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand("DELETE FROM opc WHERE id = $1");
cmd.Parameters.AddWithValue(id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}
// ── Notes ──────────────────────────────────────────────────────────────────
public async Task<List<OpcNote>> ListNotesAsync(Guid opcId, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand(
"SELECT id, opc_id, author, content, created_at FROM opc_note WHERE opc_id = $1 ORDER BY created_at ASC");
cmd.Parameters.AddWithValue(opcId);
await using var r = await cmd.ExecuteReaderAsync(ct);
var list = new List<OpcNote>();
while (await r.ReadAsync(ct)) list.Add(ReadNote(r));
return list;
}
public async Task<OpcNote> AddNoteAsync(Guid opcId, AddNoteRequest req, CancellationToken ct = default)
{
var sql = """
INSERT INTO opc_note (opc_id, author, content)
VALUES ($1, $2, $3)
RETURNING id, opc_id, author, content, created_at
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(opcId);
cmd.Parameters.AddWithValue(req.Author);
cmd.Parameters.AddWithValue(req.Content);
await using var r = await cmd.ExecuteReaderAsync(ct);
await r.ReadAsync(ct);
return ReadNote(r);
}
// ── Artifacts ─────────────────────────────────────────────────────────────
public async Task<List<OpcArtifact>> ListArtifactsAsync(Guid opcId, string? artifactType = null, CancellationToken ct = default)
{
var sql = """
SELECT id, opc_id, artifact_type, title, content, created_at, updated_at
FROM opc_artifact
WHERE opc_id = $1
AND ($2::text IS NULL OR artifact_type = $2)
ORDER BY created_at ASC
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(opcId);
cmd.Parameters.AddWithValue(artifactType ?? (object)DBNull.Value);
await using var r = await cmd.ExecuteReaderAsync(ct);
var list = new List<OpcArtifact>();
while (await r.ReadAsync(ct)) list.Add(ReadArtifact(r));
return list;
}
public async Task<OpcArtifact> UpsertArtifactAsync(Guid opcId, UpsertArtifactRequest req, CancellationToken ct = default)
{
var sql = """
INSERT INTO opc_artifact (opc_id, artifact_type, title, content)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING
RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at
""";
// Simple insert; for updates use artifact id endpoint
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(opcId);
cmd.Parameters.AddWithValue(req.ArtifactType);
cmd.Parameters.AddWithValue(req.Title);
cmd.Parameters.AddWithValue(req.Content);
await using var r = await cmd.ExecuteReaderAsync(ct);
await r.ReadAsync(ct);
return ReadArtifact(r);
}
public async Task<OpcArtifact?> UpdateArtifactAsync(Guid artifactId, UpsertArtifactRequest req, CancellationToken ct = default)
{
var sql = """
UPDATE opc_artifact SET
title = $2,
content = $3,
updated_at = NOW()
WHERE id = $1
RETURNING id, opc_id, artifact_type, title, content, created_at, updated_at
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(artifactId);
cmd.Parameters.AddWithValue(req.Title);
cmd.Parameters.AddWithValue(req.Content);
await using var r = await cmd.ExecuteReaderAsync(ct);
return await r.ReadAsync(ct) ? ReadArtifact(r) : null;
}
public async Task<bool> DeleteArtifactAsync(Guid artifactId, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand("DELETE FROM opc_artifact WHERE id = $1");
cmd.Parameters.AddWithValue(artifactId);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}
// ── Pinned commits ────────────────────────────────────────────────────────
private static OpcPinnedCommit ReadPinnedCommit(NpgsqlDataReader r) => new(
r.GetGuid(0),
r.GetString(1),
r.GetString(2),
r.GetString(3),
r.GetString(4),
r.GetDateTime(5),
r.GetString(6)
);
public async Task<List<OpcPinnedCommit>> ListPinnedCommitsAsync(Guid opcId, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand(
"SELECT opc_id, hash, short_hash, subject, author, pinned_at, pinned_by FROM opc_pinned_commit WHERE opc_id = $1 ORDER BY pinned_at DESC");
cmd.Parameters.AddWithValue(opcId);
await using var r = await cmd.ExecuteReaderAsync(ct);
var list = new List<OpcPinnedCommit>();
while (await r.ReadAsync(ct)) list.Add(ReadPinnedCommit(r));
return list;
}
public async Task<OpcPinnedCommit?> PinCommitAsync(
Guid opcId, string hash, string shortHash, string subject, string author, string pinnedBy,
CancellationToken ct = default)
{
// Verify the OPC exists
await using var existsCmd = db.CreateCommand("SELECT 1 FROM opc WHERE id = $1");
existsCmd.Parameters.AddWithValue(opcId);
var exists = await existsCmd.ExecuteScalarAsync(ct);
if (exists is null) return null;
var sql = """
INSERT INTO opc_pinned_commit (opc_id, hash, short_hash, subject, author, pinned_by)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (opc_id, hash) DO UPDATE SET
short_hash = EXCLUDED.short_hash,
subject = EXCLUDED.subject,
author = EXCLUDED.author,
pinned_by = EXCLUDED.pinned_by,
pinned_at = NOW()
RETURNING opc_id, hash, short_hash, subject, author, pinned_at, pinned_by
""";
await using var cmd = db.CreateCommand(sql);
cmd.Parameters.AddWithValue(opcId);
cmd.Parameters.AddWithValue(hash);
cmd.Parameters.AddWithValue(shortHash);
cmd.Parameters.AddWithValue(subject);
cmd.Parameters.AddWithValue(author);
cmd.Parameters.AddWithValue(pinnedBy);
await using var r = await cmd.ExecuteReaderAsync(ct);
return await r.ReadAsync(ct) ? ReadPinnedCommit(r) : null;
}
public async Task<bool> UnpinCommitAsync(Guid opcId, string hash, CancellationToken ct = default)
{
await using var cmd = db.CreateCommand(
"DELETE FROM opc_pinned_commit WHERE opc_id = $1 AND hash = $2");
cmd.Parameters.AddWithValue(opcId);
cmd.Parameters.AddWithValue(hash);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}
}
@@ -0,0 +1,127 @@
using ControlPlane.Core.Models;
using ControlPlane.Core.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
namespace ControlPlane.Api.Services;
/// <summary>
/// Runs dotnet build or npm run build for individual projects in the repo.
/// Used by the Build Monitor tab in the control plane UI.
/// </summary>
public class ProjectBuildService(
IConfiguration config,
BuildHistoryService history,
ILogger<ProjectBuildService> logger)
{
public string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
/// <summary>Known projects in the solution, returned to the UI for the build monitor grid.</summary>
public IReadOnlyList<ProjectDefinition> GetProjects()
{
if (string.IsNullOrWhiteSpace(RepoRoot)) return [];
return
[
new("Clarity.Server", BuildKind.DotnetProject, "Clarity.Server/Clarity.Server.csproj"),
new("Clarity.ServiceDefaults", BuildKind.DotnetProject, "Clarity.ServiceDefaults/Clarity.ServiceDefaults.csproj"),
new("frontend (Clarity.Server)", BuildKind.NpmProject, "frontend"),
];
}
/// <summary>
/// Builds a single project and streams output to <paramref name="onLine"/>.
/// </summary>
public async Task<BuildRecord> BuildProjectAsync(
string projectName,
Action<string> onLine,
CancellationToken ct)
{
var projects = GetProjects();
var def = projects.FirstOrDefault(p => p.Name == projectName);
if (def is null)
{
var err = new BuildRecord { Kind = BuildKind.DotnetProject, Target = projectName, Status = BuildStatus.Failed };
err.Log.Add($"Unknown project: {projectName}");
return err;
}
var record = await history.CreateBuildAsync(def.Kind, def.RelativePath);
record.Log.Add($"▶ Building {def.Name} [{def.Kind}]");
record.Log.Add($" Path: {def.RelativePath}");
record.Log.Add("──────────────────────────────────────");
onLine($"▶ Building {def.Name}");
try
{
var (exe, args, workDir) = def.Kind == BuildKind.NpmProject
? BuildNpmCommand(def.RelativePath)
: BuildDotnetCommand(def.RelativePath);
var psi = new ProcessStartInfo(exe, args)
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
void HandleLine(string? line)
{
if (line is null) return;
record.Log.Add(line);
onLine(line);
// Non-blocking fire-and-forget flush
_ = history.AppendBuildLogAsync(record, line);
}
proc.OutputDataReceived += (_, e) => HandleLine(e.Data);
proc.ErrorDataReceived += (_, e) => HandleLine(e.Data);
proc.Start();
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
await proc.WaitForExitAsync(ct);
var status = proc.ExitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed;
var summary = proc.ExitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {proc.ExitCode}).";
onLine("──────────────────────────────────────");
onLine(summary);
record.Log.Add(summary);
await history.CompleteBuildAsync(record, status);
logger.LogInformation("Project build [{Name}] {Status}", def.Name, status);
return record;
}
catch (Exception ex)
{
onLine($"✖ Exception: {ex.Message}");
record.Log.Add($"Exception: {ex.Message}");
await history.CompleteBuildAsync(record, BuildStatus.Failed);
logger.LogError(ex, "Project build [{Name}] threw.", def.Name);
return record;
}
}
private (string exe, string args, string workDir) BuildDotnetCommand(string relativePath)
{
var fullPath = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
return ("dotnet", $"build \"{fullPath}\" --configuration Release --nologo", RepoRoot);
}
private (string exe, string args, string workDir) BuildNpmCommand(string relativePath)
{
var workDir = Path.Combine(RepoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
// npm on Windows needs cmd /c
return (OperatingSystem.IsWindows() ? "cmd" : "sh",
OperatingSystem.IsWindows() ? "/c npm run build" : "-c \"npm run build\"",
workDir);
}
}
public record ProjectDefinition(string Name, BuildKind Kind, string RelativePath);
@@ -0,0 +1,283 @@
using ControlPlane.Core.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Text.Json;
namespace ControlPlane.Api.Services;
/// <summary>
/// Handles all git operations for the promotion workflow:
/// branch status, diff summaries, merge + push, and promotion history persistence.
/// All git commands run against the repo root configured in Docker:RepoRoot.
/// </summary>
public class PromotionService(IConfiguration config, ILogger<PromotionService> logger)
{
// The ordered promotion ladder — each step is a valid promotion.
public static readonly string[] Ladder = ["develop", "staging", "uat", "master"];
private string RepoRoot => config["Docker:RepoRoot"] ?? string.Empty;
private static readonly SemaphoreSlim _lock = new(1, 1);
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
// ── Branch status ────────────────────────────────────────────────────────
/// <summary>
/// Returns status for all ladder branches: last commit info + ahead/behind counts vs next branch.
/// </summary>
public async Task<List<BranchStatus>> GetLadderStatusAsync(CancellationToken ct = default)
{
var result = new List<BranchStatus>();
// Fetch to get up-to-date remote state, but don't fail if we're offline
await RunGitAsync("fetch --all --quiet", ct, swallowErrors: true);
foreach (var branch in Ladder)
{
var exists = await BranchExistsAsync(branch, ct);
if (!exists)
{
result.Add(new BranchStatus(branch, false, null, null, 0, 0, []));
continue;
}
// Last commit on this branch
var lastCommit = await GitOutputAsync($"log {branch} -1 --format=%h|%an|%ad|%s --date=short", ct);
string? shortHash = null, author = null, date = null, subject = null;
if (!string.IsNullOrWhiteSpace(lastCommit))
{
var p = lastCommit.Trim().Split('|', 4);
if (p.Length == 4) (shortHash, author, date, subject) = (p[0], p[1], p[2], p[3]);
}
// Ahead/behind vs the NEXT branch in the ladder
int ahead = 0, behind = 0;
var nextIdx = Array.IndexOf(Ladder, branch) + 1;
if (nextIdx < Ladder.Length)
{
var next = Ladder[nextIdx];
if (await BranchExistsAsync(next, ct))
{
var counts = await GitOutputAsync($"rev-list --left-right --count {next}...{branch}", ct);
if (!string.IsNullOrWhiteSpace(counts))
{
var parts = counts.Trim().Split('\t');
if (parts.Length == 2)
{
int.TryParse(parts[0], out behind);
int.TryParse(parts[1], out ahead);
}
}
}
}
// Unreleased commit summaries (commits in this branch not yet in next)
string[] unreleasedLines = [];
if (ahead > 0 && nextIdx < Ladder.Length && await BranchExistsAsync(Ladder[nextIdx], ct))
{
var log = await GitOutputAsync($"log {Ladder[nextIdx]}..{branch} --oneline --no-decorate", ct);
unreleasedLines = log.Split('\n', StringSplitOptions.RemoveEmptyEntries);
}
result.Add(new BranchStatus(branch, true, shortHash, $"{author} · {date} · {subject}",
ahead, behind, unreleasedLines));
}
return result;
}
// ── Promotion ────────────────────────────────────────────────────────────
/// <summary>
/// Merges <paramref name="from"/> into <paramref name="to"/> with a no-fast-forward merge commit,
/// then pushes. Streams progress lines to <paramref name="onLine"/>.
/// </summary>
public async Task<PromotionRequest> PromoteAsync(
string from,
string to,
string requestedBy,
string? note,
Action<string> onLine,
CancellationToken ct)
{
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
{
var busy = new PromotionRequest { FromBranch = from, ToBranch = to, Status = PromotionStatus.Failed };
busy.Log.Add("⚠️ Another promotion is already in progress.");
return busy;
}
var req = new PromotionRequest
{
FromBranch = from,
ToBranch = to,
RequestedBy = requestedBy,
Note = note,
Status = PromotionStatus.Running,
};
void Log(string line) { req.Log.Add(line); onLine(line); }
try
{
Log($"▶ Promoting {from} → {to}");
if (!string.IsNullOrWhiteSpace(note)) Log($" Note: {note}");
Log("──────────────────────────────────────");
// 1. Fetch latest
Log(" git fetch --all");
await RunGitAsync("fetch --all --quiet", ct);
// 2. Checkout target branch
Log($" git checkout {to}");
await RunGitAsync($"checkout {to}", ct);
// 3. Pull target to latest
Log($" git pull origin {to}");
await RunGitAsync($"pull origin {to} --quiet", ct);
// 4. Count commits being promoted
var logOutput = await GitOutputAsync($"log {to}..{from} --oneline --no-decorate", ct);
var commitLines = logOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries);
req.CommitCount = commitLines.Length;
req.CommitLines = commitLines;
Log($" Merging {commitLines.Length} commit(s) from {from}:");
foreach (var cl in commitLines) Log($" {cl}");
// 5. Merge with --no-ff for a clean promotion commit
var mergeMsg = $"chore: promote {from} → {to}" + (note != null ? $" — {note}" : "");
Log($" git merge --no-ff {from}");
await RunGitAsync($"merge --no-ff {from} -m \"{mergeMsg}\"", ct);
// 6. Push
Log($" git push origin {to}");
await RunGitAsync($"push origin {to}", ct);
// 7. Return to develop so the working tree stays clean
await RunGitAsync("checkout develop", ct, swallowErrors: true);
Log("──────────────────────────────────────");
Log($"✔ {from} → {to} promoted successfully at {DateTimeOffset.UtcNow:u}");
req.Status = PromotionStatus.Succeeded;
req.CompletedAt = DateTimeOffset.UtcNow;
}
catch (Exception ex)
{
Log($"✖ Promotion failed: {ex.Message}");
req.Status = PromotionStatus.Failed;
req.CompletedAt = DateTimeOffset.UtcNow;
// Try to abort any broken merge state
await RunGitAsync("merge --abort", ct, swallowErrors: true);
await RunGitAsync("checkout develop", ct, swallowErrors: true);
logger.LogError(ex, "Promotion {From}→{To} failed", from, to);
}
finally
{
await SaveAsync(req);
_lock.Release();
}
return req;
}
// ── History persistence ──────────────────────────────────────────────────
private string HistoryPath
{
get
{
var folder = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"]
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets"));
Directory.CreateDirectory(folder);
return Path.Combine(folder, "promotions.json");
}
}
private static readonly SemaphoreSlim _fileLock = new(1, 1);
private async Task SaveAsync(PromotionRequest req)
{
await _fileLock.WaitAsync();
try
{
var all = LoadHistory();
var idx = all.FindIndex(r => r.Id == req.Id);
if (idx >= 0) all[idx] = req; else all.Insert(0, req);
if (all.Count > 100) all = all[..100];
await File.WriteAllTextAsync(HistoryPath, JsonSerializer.Serialize(all, JsonOpts));
}
finally { _fileLock.Release(); }
}
public async Task<List<PromotionRequest>> GetHistoryAsync()
{
await _fileLock.WaitAsync();
try { return LoadHistory(); }
finally { _fileLock.Release(); }
}
private List<PromotionRequest> LoadHistory()
{
if (!File.Exists(HistoryPath)) return [];
try { return JsonSerializer.Deserialize<List<PromotionRequest>>(File.ReadAllText(HistoryPath), JsonOpts) ?? []; }
catch { return []; }
}
// ── Git helpers ──────────────────────────────────────────────────────────
private async Task<bool> BranchExistsAsync(string branch, CancellationToken ct)
{
var output = await GitOutputAsync($"branch --list {branch}", ct);
return !string.IsNullOrWhiteSpace(output);
}
private async Task<string> GitOutputAsync(string args, CancellationToken ct)
{
var psi = MakePsi(args);
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
var output = await proc.StandardOutput.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return output;
}
private async Task RunGitAsync(string args, CancellationToken ct, bool swallowErrors = false)
{
var psi = MakePsi(args);
using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start git");
var stderr = await proc.StandardError.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
if (!swallowErrors && proc.ExitCode != 0)
throw new InvalidOperationException($"git {args} exited {proc.ExitCode}: {stderr.Trim()}");
logger.LogDebug("git {Args} → exit {Code}", args, proc.ExitCode);
}
private ProcessStartInfo MakePsi(string args) => new("git", args)
{
WorkingDirectory = RepoRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
}
/// <summary>Current status of a single branch in the promotion ladder.</summary>
public record BranchStatus(
string Branch,
bool Exists,
string? ShortHash,
string? LastCommitSummary,
int AheadOfNext, // commits this branch has that the next doesn't
int BehindNext, // commits next has that this branch doesn't (shouldn't happen in clean flow)
string[] UnreleasedLines // oneline log of the ahead commits
);
+191
View File
@@ -0,0 +1,191 @@
using ControlPlane.Core.Models;
using ControlPlane.Core.Services;
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ControlPlane.Api.Services;
/// <summary>
/// Orchestrates a release: finds all managed tenant containers matching the target
/// environment, removes each one, and restarts it from the latest clarity-server image.
/// Does NOT re-run Keycloak/Vault/DB steps — the container env vars are preserved from
/// the original provisioning and re-injected from the XML registry.
/// </summary>
public class ReleaseService(
IConfiguration config,
TenantRegistryService registry,
BuildHistoryService history,
ILogger<ReleaseService> logger)
{
private static readonly SemaphoreSlim _lock = new(1, 1);
public bool IsReleasing => _lock.CurrentCount == 0;
public string ImageName => config["Docker:ClarityServerImage"] ?? "clarity-server:latest";
/// <summary>
/// Runs a release for the given environment and streams status lines to <paramref name="onLine"/>.
/// </summary>
public async Task<ReleaseRecord> ReleaseAsync(
string targetEnv,
Action<string> onLine,
CancellationToken ct)
{
if (!await _lock.WaitAsync(TimeSpan.Zero, ct))
{
onLine("⚠️ A release is already in progress.");
var blocked = new ReleaseRecord
{
Environment = targetEnv,
ImageName = ImageName,
Status = ReleaseStatus.Failed,
FinishedAt = DateTimeOffset.UtcNow,
};
blocked.Tenants.Add(new TenantReleaseResult
{
Subdomain = "*", ContainerName = "*",
Success = false, Error = "Release already in progress.",
});
return blocked;
}
var record = await history.CreateReleaseAsync(targetEnv, ImageName);
try
{
onLine($"▶ Release to [{targetEnv}] using {ImageName}");
onLine("──────────────────────────────────────");
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
// Find all managed tenant containers for this environment
var filterEnv = targetEnv == "all"
? new Dictionary<string, IDictionary<string, bool>>
{
["label"] = new Dictionary<string, bool> { ["clarity.managed=true"] = true },
}
: new Dictionary<string, IDictionary<string, bool>>
{
["label"] = new Dictionary<string, bool>
{
["clarity.managed=true"] = true,
[$"clarity.env={targetEnv}"] = true,
},
};
var containers = await docker.Containers.ListContainersAsync(
new ContainersListParameters { All = true, Filters = filterEnv }, ct);
if (containers.Count == 0)
{
onLine($" No managed containers found for environment [{targetEnv}].");
record.Status = ReleaseStatus.Succeeded;
record.FinishedAt = DateTimeOffset.UtcNow;
await history.UpdateReleaseAsync(record);
return record;
}
onLine($" Found {containers.Count} container(s) to redeploy.");
onLine("");
int succeeded = 0, failed = 0;
foreach (var container in containers)
{
var name = container.Names.FirstOrDefault()?.TrimStart('/') ?? container.ID[..12];
var tenantResult = new TenantReleaseResult
{
ContainerName = name,
Subdomain = container.Labels.TryGetValue("clarity.subdomain", out var sub) ? sub : name,
};
record.Tenants.Add(tenantResult);
try
{
onLine($" → {name}");
// Read env vars from existing container — preserve Keycloak/Vault/DB config
var inspect = await docker.Containers.InspectContainerAsync(container.ID, ct);
var env = inspect.Config.Env;
var labels = inspect.Config.Labels;
var network = inspect.HostConfig.NetworkMode;
// Stop and remove old container
onLine($" Stopping...");
try
{
await docker.Containers.StopContainerAsync(
container.ID, new ContainerStopParameters { WaitBeforeKillSeconds = 8 }, ct);
await docker.Containers.RemoveContainerAsync(
container.ID, new ContainerRemoveParameters { Force = true }, ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Stop/remove failed for {Name}, forcing removal.", name);
await docker.Containers.RemoveContainerAsync(
container.ID, new ContainerRemoveParameters { Force = true }, ct);
}
// Create fresh container from latest image, preserving all env vars and labels
onLine($" Creating from {ImageName}...");
var created = await docker.Containers.CreateContainerAsync(
new CreateContainerParameters
{
Name = name,
Image = ImageName,
Env = env,
Labels = labels,
HostConfig = new HostConfig
{
NetworkMode = network,
RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped },
},
}, ct);
// Start it
var started = await docker.Containers.StartContainerAsync(created.ID, null, ct);
if (!started) throw new InvalidOperationException("Docker returned false for start.");
onLine($" ✔ {name} redeployed.");
tenantResult.Success = true;
succeeded++;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to redeploy {Name}.", name);
onLine($" ✖ {name} failed: {ex.Message}");
tenantResult.Success = false;
tenantResult.Error = ex.Message;
failed++;
}
await history.UpdateReleaseAsync(record);
}
record.Status = failed == 0 ? ReleaseStatus.Succeeded
: succeeded == 0 ? ReleaseStatus.Failed
: ReleaseStatus.PartialFailure;
record.FinishedAt = DateTimeOffset.UtcNow;
onLine("");
onLine("──────────────────────────────────────");
onLine($"{(record.Status == ReleaseStatus.Succeeded ? "" : "")} Release complete — {succeeded} succeeded, {failed} failed.");
}
catch (Exception ex)
{
logger.LogError(ex, "Release to [{Env}] threw an unhandled exception.", targetEnv);
record.Status = ReleaseStatus.Failed;
record.FinishedAt = DateTimeOffset.UtcNow;
onLine($"✖ Release aborted: {ex.Message}");
}
finally
{
await history.UpdateReleaseAsync(record);
_lock.Release();
}
return record;
}
}
+38
View File
@@ -0,0 +1,38 @@
using ControlPlane.Core.Messages;
using System.Collections.Concurrent;
using System.Threading.Channels;
namespace ControlPlane.Api.Services;
/// <summary>
/// Thin in-process pub/sub for SSE. MassTransit consumer writes here;
/// the SSE endpoint reads and streams to the browser.
/// </summary>
public sealed class SseEventBus
{
private readonly ConcurrentDictionary<Guid, List<Channel<ProvisioningProgressEvent>>> _subs = new();
public void Publish(ProvisioningProgressEvent evt)
{
if (!_subs.TryGetValue(evt.JobId, out var channels)) return;
lock (channels)
foreach (var ch in channels)
ch.Writer.TryWrite(evt);
}
public Channel<ProvisioningProgressEvent> Subscribe(Guid jobId)
{
var ch = Channel.CreateUnbounded<ProvisioningProgressEvent>();
_subs.GetOrAdd(jobId, _ => []).Add(ch);
return ch;
}
public void Unsubscribe(Guid jobId, Channel<ProvisioningProgressEvent> channel)
{
if (_subs.TryGetValue(jobId, out var channels))
{
lock (channels) channels.Remove(channel);
channel.Writer.TryComplete();
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Formats.Tar;
using System.IO.Compression;
namespace ControlPlane.Api.Services;
/// <summary>
/// Creates a gzipped tar stream from a directory, respecting .dockerignore rules.
/// Used to supply the Docker build context to the Docker SDK.
/// </summary>
internal static class TarHelper
{
private static readonly string[] DefaultIgnore =
[
".git", ".vs", ".vscode", "node_modules", "bin", "obj",
"VaultData", "*.user", "*.suo",
];
public static void Pack(string root, Stream destination)
{
var ignorePatterns = LoadDockerIgnore(root);
using var gz = new GZipStream(destination, CompressionLevel.Fastest, leaveOpen: true);
using var tar = new TarWriter(gz, TarEntryFormat.Gnu, leaveOpen: false);
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(root, file).Replace('\\', '/');
if (ShouldIgnore(relative, ignorePatterns))
continue;
var entry = new GnuTarEntry(TarEntryType.RegularFile, relative)
{
DataStream = File.OpenRead(file),
};
tar.WriteEntry(entry);
}
}
private static List<string> LoadDockerIgnore(string root)
{
var path = Path.Combine(root, ".dockerignore");
var patterns = new List<string>(DefaultIgnore);
if (!File.Exists(path)) return patterns;
foreach (var line in File.ReadAllLines(path))
{
var trimmed = line.Trim();
if (!string.IsNullOrEmpty(trimmed) && !trimmed.StartsWith('#'))
patterns.Add(trimmed);
}
return patterns;
}
private static bool ShouldIgnore(string relativePath, List<string> patterns)
{
var segments = relativePath.Split('/');
foreach (var pattern in patterns)
{
var p = pattern.TrimStart('/').TrimEnd('/');
// Glob suffix match (e.g. *.user)
if (p.StartsWith('*'))
{
if (relativePath.EndsWith(p[1..], StringComparison.OrdinalIgnoreCase))
return true;
continue;
}
// Exact full-path match or root-anchored prefix (e.g. .git, .vs)
if (relativePath.Equals(p, StringComparison.OrdinalIgnoreCase))
return true;
if (relativePath.StartsWith(p + "/", StringComparison.OrdinalIgnoreCase))
return true;
// Match any path segment so that nested bin/, obj/, node_modules/ etc. are caught
// regardless of which project subdirectory they live in.
if (segments.Any(seg => seg.Equals(p, StringComparison.OrdinalIgnoreCase)))
return true;
}
return false;
}
}