using System.Diagnostics; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; using System.Text.Json; using ControlPlane.Core.Messages; using ControlPlane.Core.Models; using ControlPlane.Core.Services; using LibGit2Sharp; using MassTransit; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace ControlPlane.Worker; /// /// MassTransit consumer. Triggered by BuildRequestedCommand (published by the Gitea push webhook). /// Clones or updates the repo, runs dotnet build, and reports status back to Gitea. /// Runs inside the SDK-based Worker container — dotnet CLI is always available. /// public sealed class BuildConsumer( BuildHistoryService history, IConfiguration config, IHttpClientFactory httpFactory, ILogger logger) : IConsumer { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public async Task Consume(ConsumeContext context) { var cmd = context.Message; var ct = context.CancellationToken; logger.LogInformation( "BuildConsumer: starting build for {Repo}@{Sha}", cmd.RepoName, cmd.HeadSha[..Math.Min(8, cmd.HeadSha.Length)]); // 1. Create a build record — CommitSha written on Complete var record = await history.CreateBuildAsync(BuildKind.SolutionBuild, cmd.SolutionPath); record.CommitSha = cmd.HeadSha; // 2. Signal pending to Gitea immediately so the commit shows ⏳ await PostCommitStatusAsync(cmd.RepoName, cmd.HeadSha, "pending", "Build running…", ct); try { // 3. Ensure repo is cloned / up-to-date var workDir = await Task.Run(() => EnsureRepo(cmd.RepoName, cmd.HeadSha, record), ct); if (workDir is null) { await FailAsync(record, cmd, "Failed to prepare repository clone.", ct); return; } // 4. Run dotnet build var solutionPath = Path.Combine(workDir, cmd.SolutionPath .Replace('/', Path.DirectorySeparatorChar)); var exitCode = await RunBuildAsync(solutionPath, record, ct); var status = exitCode == 0 ? BuildStatus.Succeeded : BuildStatus.Failed; var summary = exitCode == 0 ? "✔ Build succeeded." : $"✖ Build failed (exit {exitCode})."; record.Log.Add(summary); logger.LogInformation("BuildConsumer: {Repo} build {Status}", cmd.RepoName, status); // 5. Persist final record + post status to Gitea await history.CompleteBuildAsync(record, status); await PostCommitStatusAsync( cmd.RepoName, cmd.HeadSha, exitCode == 0 ? "success" : "failure", summary, ct); } catch (Exception ex) { logger.LogError(ex, "BuildConsumer: unhandled exception for {Repo}@{Sha}", cmd.RepoName, cmd.HeadSha); await FailAsync(record, cmd, $"Unhandled exception: {ex.Message}", ct); } } // ── Repository management ───────────────────────────────────────────────── private string? EnsureRepo(string repoName, string headSha, BuildRecord record) { var baseDir = config["Build:WorkDir"] ?? "/opt/clarity-builds"; var repoDir = Path.Combine(baseDir, repoName); var remoteUrl = BuildRemoteUrl(repoName); void Log(string msg) { record.Log.Add(msg); logger.LogInformation("[{Repo}] {Msg}", repoName, msg); } try { if (!Repository.IsValid(repoDir)) { Log($"Cloning {remoteUrl} → {repoDir}"); Directory.CreateDirectory(repoDir); Repository.Clone(remoteUrl, repoDir, new CloneOptions { FetchOptions = { CredentialsProvider = MakeCredentials(), CertificateCheck = (_, _, _) => true, }, }); } else { Log($"Pulling latest for {repoName}"); using var repo = new Repository(repoDir); var remote = EnsureRemote(repo, repoName); Commands.Fetch(repo, remote.Name, remote.FetchRefSpecs.Select(r => r.Specification), new FetchOptions { CredentialsProvider = MakeCredentials(), CertificateCheck = (_, _, _) => true, }, null); // Reset to the exact SHA we want to build var commit = repo.Lookup(headSha); if (commit is null) { Log($"Warning: SHA {headSha[..8]} not found after fetch — building HEAD instead."); } else { repo.Reset(ResetMode.Hard, commit); Log($"Reset to {headSha[..8]}"); } } return repoDir; } catch (Exception ex) { Log($"✖ Git error: {ex.Message}"); logger.LogError(ex, "Failed to prepare repo {Repo}", repoName); return null; } } private Remote EnsureRemote(Repository repo, string repoName) { var url = BuildRemoteUrl(repoName); var remote = repo.Network.Remotes["origin"]; if (remote is null) return repo.Network.Remotes.Add("origin", url); if (remote.Url != url) repo.Network.Remotes.Update("origin", r => r.Url = url); return repo.Network.Remotes["origin"]!; } private string BuildRemoteUrl(string repoName) { var baseUrl = (config["Gitea:BaseUrl"] ?? "https://opc.clarity.test").TrimEnd('/'); var owner = config["Gitea:Owner"] ?? "ClarityStack"; return $"{baseUrl}/{owner}/{repoName}.git"; } private LibGit2Sharp.Handlers.CredentialsHandler MakeCredentials() { var user = config["Gitea:Owner"] ?? "git"; var token = config["Gitea:Token"] ?? string.Empty; return (_, _, _) => new UsernamePasswordCredentials { Username = user, Password = token }; } // ── Build execution ─────────────────────────────────────────────────────── private async Task RunBuildAsync(string solutionPath, BuildRecord record, CancellationToken ct) { var psi = new ProcessStartInfo("dotnet", $"build \"{solutionPath}\" -c Release --no-incremental --nologo") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; record.Log.Add($"▶ dotnet build {Path.GetFileName(solutionPath)} -c Release"); record.Log.Add("──────────────────────────────────────────────────"); using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true }; void HandleLine(string? line) { if (line is null) return; // Non-blocking fire-and-forget flush every 20 lines _ = 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); return proc.ExitCode; } // ── Gitea commit status ─────────────────────────────────────────────────── private async Task PostCommitStatusAsync( string repoName, string sha, string state, string description, CancellationToken ct) { try { var baseUrl = (config["Gitea:BaseUrl"] ?? "https://opc.clarity.test").TrimEnd('/'); var owner = config["Gitea:Owner"] ?? "ClarityStack"; var token = config["Gitea:Token"] ?? string.Empty; using var http = httpFactory.CreateClient("gitea"); http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token); var url = $"{baseUrl}/api/v1/repos/{owner}/{repoName}/statuses/{sha}"; var body = JsonSerializer.Serialize(new { state, description, context = "controlplane/build", }, JsonOpts); var resp = await http.PostAsync( url, new StringContent(body, Encoding.UTF8, "application/json"), ct); if (!resp.IsSuccessStatusCode) { var err = await resp.Content.ReadAsStringAsync(ct); logger.LogWarning( "PostCommitStatus failed for {Repo}@{Sha}: {Status} {Err}", repoName, sha[..Math.Min(8, sha.Length)], resp.StatusCode, err); } } catch (Exception ex) { // Never let a failed status post break the build flow logger.LogWarning(ex, "PostCommitStatus threw for {Repo}", repoName); } } // ── Helpers ─────────────────────────────────────────────────────────────── private async Task FailAsync( BuildRecord record, BuildRequestedCommand cmd, string reason, CancellationToken ct) { record.Log.Add($"✖ {reason}"); await history.CompleteBuildAsync(record, BuildStatus.Failed); await PostCommitStatusAsync(cmd.RepoName, cmd.HeadSha, "failure", reason, ct); } }