3 Commits

Author SHA1 Message Date
amadzarak c78bcf3360 OPC # 0009: Fix Worker gitea token
controlplane/build ✔ Build succeeded.
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:16:53 -04:00
amadzarak ff7fa8e812 OPC # 0009: Fix Worker gitea HttpClient missing token and base URL
controlplane/build ✔ Build succeeded.
2026-04-26 16:15:05 -04:00
amadzarak 13ff5eb926 OPC # 0009: Gitea and OPC Build Webhooks 2026-04-26 16:12:00 -04:00
16 changed files with 624 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.MapOpcEndpoints();
app.MapGiteaEndpoints(); app.MapGiteaEndpoints();
app.MapInfraEndpoints(); app.MapInfraEndpoints();
app.MapGiteaWebhookEndpoints();
// Ensure OPC tables exist (idempotent — IF NOT EXISTS) // Ensure OPC tables exist (idempotent — IF NOT EXISTS)
var ds = app.Services.GetRequiredService<NpgsqlDataSource>(); var ds = app.Services.GetRequiredService<NpgsqlDataSource>();
+34
View File
@@ -251,6 +251,40 @@ public class GiteaService
return await res.Content.ReadFromJsonAsync<GiteaWebhook>(JsonOpts, ct); 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 ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
private static string SlugifyTitle(string title) => private static string SlugifyTitle(string title) =>
+8 -4
View File
@@ -18,14 +18,18 @@
} }
}, },
"Gitea": { "Gitea": {
"BaseUrl": "https://opc.clarity.test", "BaseUrl": "https://opc.clarity.test",
"Owner": "ClarityStack", "Owner": "ClarityStack",
"Repo": "OPC", "Repo": "OPC",
"Token": "fcf9f66415754fb639a8343e3904e06b1d78c646", "Token": "fcf9f66415754fb639a8343e3904e06b1d78c646",
"WebhookSecret": "",
"Repos": { "Repos": {
"Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" }, "Clarity": { "Owner": "ClarityStack", "Repo": "Clarity" },
"OPC": { "Owner": "ClarityStack", "Repo": "OPC" }, "OPC": { "Owner": "ClarityStack", "Repo": "OPC" },
"Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" } "Gateway": { "Owner": "ClarityStack", "Repo": "Gateway" }
} }
},
"Build": {
"WorkDir": "C:\\Users\\amadzarak\\source\\clarity-builds"
} }
} }
+8
View File
@@ -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 nginxConfDPath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "nginx", "conf.d"));
var vaultKeysFile = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "infra", "vault", "data", "init.json")); 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 #region CONTROLPLANE POSTGRES
// ControlPlane owns this — isolated from platform infra postgres. // ControlPlane owns this — isolated from platform infra postgres.
// Override via: dotnet user-secrets set "Parameters:cp-postgres-password" "yourpassword" // 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) .WaitFor(controlPlaneDb)
.WithEnvironment("ClientAssets__Folder", clientAssetsPath) .WithEnvironment("ClientAssets__Folder", clientAssetsPath)
.WithEnvironment("Docker__RepoRoot", Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", ".."))) // ClarityStack/ root — needed for Directory.*.props .WithEnvironment("Docker__RepoRoot", Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", ".."))) // ClarityStack/ root — needed for Directory.*.props
.WithEnvironment("Build__WorkDir", buildWorkDir)
.WithExternalHttpEndpoints(); .WithExternalHttpEndpoints();
#endregion #endregion
@@ -82,6 +86,10 @@ builder.AddProject<Projects.ControlPlane_Worker>("controlplane-worker")
// Platform Postgres connection string for tenant database provisioning (infra/docker-compose.yml) // Platform Postgres connection string for tenant database provisioning (infra/docker-compose.yml)
.WithEnvironment("ConnectionStrings__platformdb", .WithEnvironment("ConnectionStrings__platformdb",
"Host=localhost;Port=5432;Username=postgres;Password=postgres") "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) .WithReference(controlPlaneDb)
.WaitFor(controlPlaneDb); .WaitFor(controlPlaneDb);
#endregion #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;
}
+253
View File
@@ -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> <ItemGroup>
<PackageReference Include="Docker.DotNet" /> <PackageReference Include="Docker.DotNet" />
<PackageReference Include="LibGit2Sharp" />
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Npgsql" /> <PackageReference Include="Npgsql" />
<PackageReference Include="Keycloak.AuthServices.Sdk" /> <PackageReference Include="Keycloak.AuthServices.Sdk" />
+2 -9
View File
@@ -14,17 +14,10 @@ RUN dotnet publish "ControlPlane.Worker/ControlPlane.Worker.csproj" \
-c Release -o /app/publish --no-restore -c Release -o /app/publish --no-restore
# ── Runtime stage ───────────────────────────────────────────────────────────── # ── 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 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 . COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"] ENTRYPOINT ["dotnet", "ControlPlane.Worker.dll"]
+18
View File
@@ -6,6 +6,7 @@ using ControlPlane.Worker.Services;
using ControlPlane.Worker.Steps; using ControlPlane.Worker.Steps;
using Keycloak.AuthServices.Sdk; using Keycloak.AuthServices.Sdk;
using MassTransit; using MassTransit;
using Npgsql;
var builder = Host.CreateApplicationBuilder(args); 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) // Custom admin client - handles realm creation, roles, role assignment (not in SDK)
builder.Services.AddSingleton<KeycloakAdminClient>(); 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 // Docker container manager for per-tenant Clarity.Server instances
builder.Services.AddSingleton<ClarityContainerService>(); builder.Services.AddSingleton<ClarityContainerService>();
@@ -44,6 +61,7 @@ builder.Services.AddMassTransit(x =>
x.SetKebabCaseEndpointNameFormatter(); x.SetKebabCaseEndpointNameFormatter();
x.AddConsumer<ProvisioningConsumer>(); x.AddConsumer<ProvisioningConsumer>();
x.AddConsumer<BuildConsumer>();
x.UsingRabbitMq((ctx, cfg) => x.UsingRabbitMq((ctx, cfg) =>
{ {
@@ -1,5 +1,8 @@
{ {
"Vault": { "Vault": {
"KeysFile": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC\\infra\\vault\\data\\init.json" "KeysFile": "C:\\Users\\amadzarak\\source\\repos\\ClarityStack\\OPC\\infra\\vault\\data\\init.json"
},
"Build": {
"WorkDir": "C:\\Users\\amadzarak\\source\\clarity-builds"
} }
} }
+8
View File
@@ -26,6 +26,14 @@
"ContainerAddress": "http://vault:8200" "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) ───────────────────────────────────── // ── ClarityInfraOptions (Clarity section) ─────────────────────────────────────
// These values describe what gets injected INTO tenant containers at docker run time. // 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). // Containers live on clarity-net → use Docker DNS names (keycloak, vault, postgres).
+8
View File
@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
+52
View File
@@ -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>
+1
View File
@@ -177,6 +177,7 @@ services:
GITEA__server__SSH_DOMAIN: opc.clarity.test GITEA__server__SSH_DOMAIN: opc.clarity.test
GITEA__server__SSH_PORT: "2222" GITEA__server__SSH_PORT: "2222"
GITEA__service__DISABLE_REGISTRATION: "true" GITEA__service__DISABLE_REGISTRATION: "true"
GITEA__webhook__ALLOWED_HOST_LIST: "host.docker.internal,loopback,private"
volumes: volumes:
- clarity-gitea-data:/data - clarity-gitea-data:/data
depends_on: depends_on:
+45
View File
@@ -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."