Compare commits
3 Commits
uat
...
c78bcf3360
| Author | SHA1 | Date | |
|---|---|---|---|
| c78bcf3360 | |||
| ff7fa8e812 | |||
| 13ff5eb926 |
@@ -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) =>
|
||||
|
||||
@@ -22,10 +22,14 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ var clientAssetsPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "
|
||||
var nginxConfDPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "nginx", "conf.d"));
|
||||
var vaultKeysFile = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "vault", "data", "init.json"));
|
||||
|
||||
// Build working directory for BuildConsumer (clone/pull repos here when running builds)
|
||||
var buildWorkDir = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "..", "clarity-builds"));
|
||||
|
||||
#region CONTROLPLANE POSTGRES
|
||||
// ControlPlane owns this — isolated from platform infra postgres.
|
||||
// Override via: dotnet user-secrets set "Parameters:cp-postgres-password" "yourpassword"
|
||||
@@ -48,6 +51,7 @@ var api = builder.AddProject<Projects.ControlPlane_Api>("controlplane-api")
|
||||
.WaitFor(controlPlaneDb)
|
||||
.WithEnvironment("ClientAssets__Folder", clientAssetsPath)
|
||||
.WithEnvironment("Docker__RepoRoot", Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", ".."))) // ClarityStack/ root — needed for Directory.*.props
|
||||
.WithEnvironment("Build__WorkDir", buildWorkDir)
|
||||
.WithExternalHttpEndpoints();
|
||||
#endregion
|
||||
|
||||
@@ -82,6 +86,10 @@ builder.AddProject<Projects.ControlPlane_Worker>("controlplane-worker")
|
||||
// Platform Postgres connection string for tenant database provisioning (infra/docker-compose.yml)
|
||||
.WithEnvironment("ConnectionStrings__platformdb",
|
||||
"Host=localhost;Port=5432;Username=postgres;Password=postgres")
|
||||
.WithEnvironment("Build__WorkDir", buildWorkDir)
|
||||
.WithEnvironment("Gitea__BaseUrl", "https://opc.clarity.test")
|
||||
.WithEnvironment("Gitea__Owner", "ClarityStack")
|
||||
.WithEnvironment("Gitea__Token", "fcf9f66415754fb639a8343e3904e06b1d78c646")
|
||||
.WithReference(controlPlaneDb)
|
||||
.WaitFor(controlPlaneDb);
|
||||
#endregion
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ControlPlane.Core.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// API -> Worker: run dotnet build for the given repo at the given commit SHA.
|
||||
/// Published when Gitea fires a push webhook for refs/heads/develop.
|
||||
/// </summary>
|
||||
public record BuildRequestedCommand
|
||||
{
|
||||
/// <summary>Gitea repo name (e.g. "OPC" or "Clarity").</summary>
|
||||
public string RepoName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>HEAD commit SHA of the push that triggered this build.</summary>
|
||||
public string HeadSha { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Branch that was pushed to (e.g. "develop").</summary>
|
||||
public string Branch { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Relative path to the solution file to build, e.g. "ControlPlane.slnx".
|
||||
/// Relative to the cloned repo root.
|
||||
/// </summary>
|
||||
public string SolutionPath { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,22 @@ 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 + token auth)
|
||||
builder.Services.AddHttpClient("gitea", (sp, client) =>
|
||||
{
|
||||
var cfg = sp.GetRequiredService<IConfiguration>();
|
||||
client.BaseAddress = new Uri(cfg["Gitea:BaseUrl"] ?? "https://opc.clarity.test");
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("token", cfg["Gitea:Token"]);
|
||||
}).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 +61,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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"ContainerAddress": "http://vault:8200"
|
||||
},
|
||||
|
||||
// ── Gitea ─────────────────────────────────────────────────────────────────────
|
||||
// Used by BuildConsumer to post commit statuses and clone repos.
|
||||
"Gitea": {
|
||||
"BaseUrl": "https://opc.clarity.test",
|
||||
"Owner": "ClarityStack",
|
||||
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646"
|
||||
},
|
||||
|
||||
// ── ClarityInfraOptions (Clarity section) ─────────────────────────────────────
|
||||
// These values describe what gets injected INTO tenant containers at docker run time.
|
||||
// Containers live on clarity-net → use Docker DNS names (keycloak, vault, postgres).
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<!-- Aspire Packages -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Keycloak.Authentication" Version="13.2.2-preview.1.26207.2" />
|
||||
<PackageVersion Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.StackExchange.Redis.OutputCaching" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Keycloak" Version="13.2.2-preview.1.26207.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.Redis" Version="13.2.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.Hosting.Minio" Version="13.2.1-beta.532" />
|
||||
<PackageVersion Include="CommunityToolkit.Aspire.Minio.Client" Version="13.2.1-beta.532" />
|
||||
<PackageVersion Include="Docker.DotNet" Version="3.125.15" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Npgsql" Version="10.0.2" />
|
||||
<PackageVersion Include="LibGit2Sharp" Version="0.31.0" />
|
||||
<PackageVersion Include="Scalar.Aspire" Version="0.9.24" />
|
||||
<PackageVersion Include="VaultSharp" Version="1.17.5.1" />
|
||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
<!-- Clarity.MigrationService -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
<!-- ControlPlane -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.Sdk" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.Hosting.RabbitMQ" Version="13.2.2" />
|
||||
<PackageVersion Include="Aspire.RabbitMQ.Client" Version="13.2.2" />
|
||||
<PackageVersion Include="Keycloak.AuthServices.Sdk" Version="2.9.0" />
|
||||
<PackageVersion Include="MassTransit" Version="8.4.1" />
|
||||
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
<!-- Clarity.Server -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.5.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.2" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -177,6 +177,7 @@ services:
|
||||
GITEA__server__SSH_DOMAIN: opc.clarity.test
|
||||
GITEA__server__SSH_PORT: "2222"
|
||||
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: "host.docker.internal,loopback,private"
|
||||
volumes:
|
||||
- clarity-gitea-data:/data
|
||||
depends_on:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# OPC # 0004: One-time migration — make postgres-data and minio-data external volumes
|
||||
# Run this ONCE before doing `docker compose up -d` with the updated docker-compose.yml.
|
||||
# Safe to run while containers are stopped. Do NOT run while containers are running.
|
||||
#
|
||||
# Usage:
|
||||
# cd OPC/scripts
|
||||
# .\create-external-volumes.ps1
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Migrate-Volume($composeName, $externalName) {
|
||||
Write-Host "`nMigrating: $composeName -> $externalName" -ForegroundColor Cyan
|
||||
|
||||
$existing = docker volume ls --format "{{.Name}}" | Where-Object { $_ -eq $externalName }
|
||||
if ($existing) {
|
||||
Write-Host " Volume '$externalName' already exists — skipping copy." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
# Check source exists
|
||||
$source = docker volume ls --format "{{.Name}}" | Where-Object { $_ -eq $composeName }
|
||||
if (-not $source) {
|
||||
Write-Host " Source volume '$composeName' not found — creating empty external volume." -ForegroundColor Yellow
|
||||
docker volume create $externalName | Out-Null
|
||||
return
|
||||
}
|
||||
|
||||
# Create external volume
|
||||
docker volume create $externalName | Out-Null
|
||||
Write-Host " Created '$externalName'"
|
||||
|
||||
# Copy data using a temporary alpine container
|
||||
docker run --rm `
|
||||
-v "${composeName}:/from" `
|
||||
-v "${externalName}:/to" `
|
||||
alpine sh -c "cd /from && cp -a . /to"
|
||||
Write-Host " Data copied." -ForegroundColor Green
|
||||
}
|
||||
|
||||
# The compose project name prefixes anonymous volumes as "clarity-platform_<name>"
|
||||
Migrate-Volume "clarity-platform_postgres-data" "postgres-data"
|
||||
Migrate-Volume "clarity-platform_minio-data" "minio-data"
|
||||
|
||||
Write-Host "`nDone. You can now run: docker compose up -d" -ForegroundColor Green
|
||||
Write-Host "The old 'clarity-platform_postgres-data' and 'clarity-platform_minio-data' volumes can be removed with 'docker volume rm' once you've verified everything is working."
|
||||
Reference in New Issue
Block a user