OPC # 0009: Gitea and OPC Build Webhooks
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
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;
|
||||
|
||||
/// <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 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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Docker.DotNet" />
|
||||
<PackageReference Include="LibGit2Sharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Keycloak.AuthServices.Sdk" />
|
||||
|
||||
@@ -14,17 +14,10 @@ RUN dotnet publish "ControlPlane.Worker/ControlPlane.Worker.csproj" \
|
||||
-c Release -o /app/publish --no-restore
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
# SDK image — required so BuildConsumer can invoke `dotnet build` on cloned repos.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Install Pulumi CLI so the Automation API can shell out to it
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL https://get.pulumi.com | sh \
|
||||
&& apt-get purge -y curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/root/.pulumi/bin:${PATH}"
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"]
|
||||
|
||||
@@ -6,6 +6,7 @@ using ControlPlane.Worker.Services;
|
||||
using ControlPlane.Worker.Steps;
|
||||
using Keycloak.AuthServices.Sdk;
|
||||
using MassTransit;
|
||||
using Npgsql;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
@@ -26,6 +27,16 @@ builder.Services.AddKeycloakAdminHttpClient(o =>
|
||||
// Custom admin client - handles realm creation, roles, role assignment (not in SDK)
|
||||
builder.Services.AddSingleton<KeycloakAdminClient>();
|
||||
|
||||
// Named HttpClient for Gitea commit status API (self-signed cert)
|
||||
builder.Services.AddHttpClient("gitea").ConfigurePrimaryHttpMessageHandler(() =>
|
||||
new HttpClientHandler { ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator });
|
||||
|
||||
// opcdb for build/release history tracking
|
||||
var opcConnStr = builder.Configuration.GetConnectionString("opcdb");
|
||||
builder.Services.AddSingleton(NpgsqlDataSource.Create(
|
||||
!string.IsNullOrWhiteSpace(opcConnStr) ? opcConnStr : "Host=127.0.0.1;Port=5433;Database=opcdb;Username=postgres;Password=controlplane-dev"));
|
||||
builder.Services.AddSingleton<BuildHistoryService>();
|
||||
|
||||
// Docker container manager for per-tenant Clarity.Server instances
|
||||
builder.Services.AddSingleton<ClarityContainerService>();
|
||||
|
||||
@@ -44,6 +55,7 @@ builder.Services.AddMassTransit(x =>
|
||||
x.SetKebabCaseEndpointNameFormatter();
|
||||
|
||||
x.AddConsumer<ProvisioningConsumer>();
|
||||
x.AddConsumer<BuildConsumer>();
|
||||
|
||||
x.UsingRabbitMq((ctx, cfg) =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"Vault": {
|
||||
"KeysFile": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC\\infra\\vault\\data\\init.json"
|
||||
},
|
||||
"Build": {
|
||||
"WorkDir": "C:\\Users\\amadzarak\\source\\clarity-builds"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user