Files
OPC/ControlPlane.Worker/BuildConsumer.cs
T
amadzarak ff7fa8e812
controlplane/build ✔ Build succeeded.
OPC # 0009: Fix Worker gitea HttpClient missing token and base URL
2026-04-26 16:15:05 -04:00

254 lines
9.8 KiB
C#

using System.Diagnostics;
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;
/// <summary>
/// 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.
/// </summary>
public sealed class BuildConsumer(
BuildHistoryService history,
IConfiguration config,
IHttpClientFactory httpFactory,
ILogger<BuildConsumer> logger) : IConsumer<BuildRequestedCommand>
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public async Task Consume(ConsumeContext<BuildRequestedCommand> 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<Commit>(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<int> 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 owner = config["Gitea:Owner"] ?? "ClarityStack";
using var http = httpFactory.CreateClient("gitea");
var url = $"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);
}
}