OPC # 0009: Gitea and OPC Build Webhooks
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user