OPC # 0009: Gitea and OPC Build Webhooks

This commit is contained in:
amadzarak
2026-04-26 16:12:00 -04:00
parent 2badb5264b
commit 13ff5eb926
15 changed files with 612 additions and 13 deletions
@@ -0,0 +1,159 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using ControlPlane.Core.Messages;
using MassTransit;
namespace ControlPlane.Api.Endpoints;
public static class GiteaWebhookEndpoints
{
// Map of Gitea repo name → solution file (relative to cloned repo root)
private static readonly Dictionary<string, string> RepoSolutions = new(StringComparer.OrdinalIgnoreCase)
{
["OPC"] = "ControlPlane.slnx",
["Clarity"] = "Clarity.slnx",
};
public static IEndpointRouteBuilder MapGiteaWebhookEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/hooks/gitea/push", HandlePushAsync)
.WithName("Gitea:PushWebhook")
.AllowAnonymous(); // validated via HMAC below
return app;
}
private static async Task<IResult> HandlePushAsync(
HttpRequest request,
IPublishEndpoint bus,
IConfiguration config,
ILogger<GiteaWebhookEndpointsMarker> logger,
CancellationToken ct)
{
// ── HMAC-SHA256 signature validation ─────────────────────────────────
var secret = config["Gitea:WebhookSecret"];
if (!string.IsNullOrWhiteSpace(secret))
{
if (!request.Headers.TryGetValue("X-Gitea-Signature", out var sigHeader))
return Results.Unauthorized();
var body = await ReadBodyAsync(request, ct);
var expectedSig = ComputeHmacSha256(body, secret);
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(sigHeader.ToString()),
Encoding.ASCII.GetBytes(expectedSig)))
{
logger.LogWarning("Gitea push webhook: HMAC signature mismatch — ignoring.");
return Results.Unauthorized();
}
var payload = JsonSerializer.Deserialize<GiteaPushPayload>(body, JsonOpts);
return await ProcessPayloadAsync(payload, bus, logger, ct);
}
else
{
// No secret configured — accept without validation (dev only)
logger.LogWarning("Gitea:WebhookSecret is not configured — webhook signature validation is disabled.");
var payload = await JsonSerializer.DeserializeAsync<GiteaPushPayload>(
request.Body, JsonOpts, ct);
return await ProcessPayloadAsync(payload, bus, logger, ct);
}
}
private static async Task<IResult> ProcessPayloadAsync(
GiteaPushPayload? payload,
IPublishEndpoint bus,
ILogger<GiteaWebhookEndpointsMarker> logger,
CancellationToken ct)
{
if (payload is null)
return Results.BadRequest("Could not parse push payload.");
// Only act on pushes to develop
if (payload.Ref != "refs/heads/develop")
{
logger.LogDebug("Gitea push webhook: ignoring push to {Ref}", payload.Ref);
return Results.Ok(new { skipped = true, reason = "not develop" });
}
var repoName = payload.Repository?.Name;
if (string.IsNullOrWhiteSpace(repoName) || !RepoSolutions.TryGetValue(repoName, out var solutionPath))
{
logger.LogWarning("Gitea push webhook: unknown or unsupported repo '{Repo}'", repoName);
return Results.Ok(new { skipped = true, reason = "unsupported repo" });
}
var headSha = payload.After;
if (string.IsNullOrWhiteSpace(headSha) || headSha == "0000000000000000000000000000000000000000")
{
logger.LogInformation("Gitea push webhook: branch deleted — nothing to build.");
return Results.Ok(new { skipped = true, reason = "branch deleted" });
}
logger.LogInformation(
"Gitea push webhook: queuing build for {Repo}@{Sha} ({Branch})",
repoName, headSha[..8], payload.Ref);
await bus.Publish(new BuildRequestedCommand
{
RepoName = repoName,
HeadSha = headSha,
Branch = "develop",
SolutionPath = solutionPath,
}, ct);
return Results.Accepted(value: new { queued = true, repo = repoName, sha = headSha[..8] });
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static async Task<byte[]> ReadBodyAsync(HttpRequest request, CancellationToken ct)
{
request.EnableBuffering();
using var ms = new MemoryStream();
await request.Body.CopyToAsync(ms, ct);
request.Body.Position = 0;
return ms.ToArray();
}
private static string ComputeHmacSha256(byte[] body, string secret)
{
var key = Encoding.UTF8.GetBytes(secret);
var hash = HMACSHA256.HashData(key, body);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
// Marker type for logger category
private sealed class GiteaWebhookEndpointsMarker;
}
// ── Payload DTOs ──────────────────────────────────────────────────────────────
public sealed class GiteaPushPayload
{
[JsonPropertyName("ref")]
public string Ref { get; init; } = string.Empty;
[JsonPropertyName("before")]
public string Before { get; init; } = string.Empty;
[JsonPropertyName("after")]
public string After { get; init; } = string.Empty;
[JsonPropertyName("repository")]
public GiteaPushRepo? Repository { get; init; }
}
public sealed class GiteaPushRepo
{
[JsonPropertyName("name")]
public string Name { get; init; } = string.Empty;
[JsonPropertyName("full_name")]
public string FullName { get; init; } = string.Empty;
}
+1
View File
@@ -79,6 +79,7 @@ app.MapPromotionEndpoints();
app.MapOpcEndpoints();
app.MapGiteaEndpoints();
app.MapInfraEndpoints();
app.MapGiteaWebhookEndpoints();
// Ensure OPC tables exist (idempotent — IF NOT EXISTS)
var ds = app.Services.GetRequiredService<NpgsqlDataSource>();
+34
View File
@@ -251,6 +251,40 @@ public class GiteaService
return await res.Content.ReadFromJsonAsync<GiteaWebhook>(JsonOpts, ct);
}
// ── Commit Status ─────────────────────────────────────────────────────────
/// <summary>
/// Posts a commit status to Gitea (the CI "check" shown on a commit / PR).
/// State values: "pending" | "success" | "failure" | "error".
/// Context identifies which check — use "controlplane/build" for the build gate.
/// </summary>
public async Task PostCommitStatusAsync(
string repoKey, string sha, string state, string description,
string context = "controlplane/build", CancellationToken ct = default)
{
var (owner, repo) = ResolveOwnerRepo(repoKey);
var body = JsonSerializer.Serialize(new
{
state,
description,
context,
}, JsonOpts);
try
{
var res = await _http.PostAsync(
$"repos/{owner}/{repo}/statuses/{sha}",
new StringContent(body, Encoding.UTF8, "application/json"), ct);
if (!res.IsSuccessStatusCode)
{
var err = await res.Content.ReadAsStringAsync(ct);
_log.LogWarning("Gitea PostCommitStatus {State} failed for {Sha}: {Err}", state, sha[..8], err);
}
}
catch (Exception ex) { _log.LogWarning(ex, "Gitea PostCommitStatus threw"); }
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string SlugifyTitle(string title) =>
+8 -4
View File
@@ -18,14 +18,18 @@
}
},
"Gitea": {
"BaseUrl": "https://opc.clarity.test",
"Owner": "ClarityStack",
"Repo": "OPC",
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646",
"BaseUrl": "https://opc.clarity.test",
"Owner": "ClarityStack",
"Repo": "OPC",
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646",
"WebhookSecret": "",
"Repos": {
"Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" },
"OPC": { "Owner": "ClarityStack", "Repo": "OPC" },
"Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" }
}
},
"Build": {
"WorkDir": "C:\\Users\\amadzarak\\source\\clarity-builds"
}
}