From 13ff5eb926ba61632cf0d968c3b9745587475719 Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sun, 26 Apr 2026 16:12:00 -0400 Subject: [PATCH] OPC # 0009: Gitea and OPC Build Webhooks --- .../Endpoints/GiteaWebhookEndpoints.cs | 159 +++++++++++ ControlPlane.Api/Program.cs | 1 + ControlPlane.Api/Services/GiteaService.cs | 34 +++ ControlPlane.Api/appsettings.json | 12 +- ControlPlane.AppHost/AppHost.cs | 5 + ControlPlane.Core/Messages/BuildMessages.cs | 23 ++ ControlPlane.Worker/BuildConsumer.cs | 258 ++++++++++++++++++ .../ControlPlane.Worker.csproj | 1 + ControlPlane.Worker/Dockerfile | 11 +- ControlPlane.Worker/Program.cs | 12 + .../appsettings.Development.json | 3 + Directory.Build.props | 8 + Directory.Packages.props | 52 ++++ infra/docker-compose.yml | 1 + scripts/create-external-volumes.ps1 | 45 +++ 15 files changed, 612 insertions(+), 13 deletions(-) create mode 100644 ControlPlane.Api/Endpoints/GiteaWebhookEndpoints.cs create mode 100644 ControlPlane.Core/Messages/BuildMessages.cs create mode 100644 ControlPlane.Worker/BuildConsumer.cs create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 scripts/create-external-volumes.ps1 diff --git a/ControlPlane.Api/Endpoints/GiteaWebhookEndpoints.cs b/ControlPlane.Api/Endpoints/GiteaWebhookEndpoints.cs new file mode 100644 index 0000000..f188e70 --- /dev/null +++ b/ControlPlane.Api/Endpoints/GiteaWebhookEndpoints.cs @@ -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 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 HandlePushAsync( + HttpRequest request, + IPublishEndpoint bus, + IConfiguration config, + ILogger 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(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( + request.Body, JsonOpts, ct); + return await ProcessPayloadAsync(payload, bus, logger, ct); + } + } + + private static async Task ProcessPayloadAsync( + GiteaPushPayload? payload, + IPublishEndpoint bus, + ILogger 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 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; +} diff --git a/ControlPlane.Api/Program.cs b/ControlPlane.Api/Program.cs index 1e016fa..011ada0 100644 --- a/ControlPlane.Api/Program.cs +++ b/ControlPlane.Api/Program.cs @@ -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(); diff --git a/ControlPlane.Api/Services/GiteaService.cs b/ControlPlane.Api/Services/GiteaService.cs index be70f3a..1e4caec 100644 --- a/ControlPlane.Api/Services/GiteaService.cs +++ b/ControlPlane.Api/Services/GiteaService.cs @@ -251,6 +251,40 @@ public class GiteaService return await res.Content.ReadFromJsonAsync(JsonOpts, ct); } + // ── Commit Status ───────────────────────────────────────────────────────── + + /// + /// 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. + /// + 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) => diff --git a/ControlPlane.Api/appsettings.json b/ControlPlane.Api/appsettings.json index b346775..0a2dee7 100644 --- a/ControlPlane.Api/appsettings.json +++ b/ControlPlane.Api/appsettings.json @@ -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" } } \ No newline at end of file diff --git a/ControlPlane.AppHost/AppHost.cs b/ControlPlane.AppHost/AppHost.cs index 923c19d..054de2e 100644 --- a/ControlPlane.AppHost/AppHost.cs +++ b/ControlPlane.AppHost/AppHost.cs @@ -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("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,7 @@ builder.AddProject("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) .WithReference(controlPlaneDb) .WaitFor(controlPlaneDb); #endregion diff --git a/ControlPlane.Core/Messages/BuildMessages.cs b/ControlPlane.Core/Messages/BuildMessages.cs new file mode 100644 index 0000000..6237988 --- /dev/null +++ b/ControlPlane.Core/Messages/BuildMessages.cs @@ -0,0 +1,23 @@ +namespace ControlPlane.Core.Messages; + +/// +/// 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. +/// +public record BuildRequestedCommand +{ + /// Gitea repo name (e.g. "OPC" or "Clarity"). + public string RepoName { get; init; } = string.Empty; + + /// HEAD commit SHA of the push that triggered this build. + public string HeadSha { get; init; } = string.Empty; + + /// Branch that was pushed to (e.g. "develop"). + public string Branch { get; init; } = string.Empty; + + /// + /// Relative path to the solution file to build, e.g. "ControlPlane.slnx". + /// Relative to the cloned repo root. + /// + public string SolutionPath { get; init; } = string.Empty; +} diff --git a/ControlPlane.Worker/BuildConsumer.cs b/ControlPlane.Worker/BuildConsumer.cs new file mode 100644 index 0000000..ecbdff6 --- /dev/null +++ b/ControlPlane.Worker/BuildConsumer.cs @@ -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; + +/// +/// 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. +/// +public sealed class BuildConsumer( + BuildHistoryService history, + IConfiguration config, + IHttpClientFactory httpFactory, + ILogger logger) : IConsumer +{ + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); + + public async Task Consume(ConsumeContext 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(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 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); + } +} diff --git a/ControlPlane.Worker/ControlPlane.Worker.csproj b/ControlPlane.Worker/ControlPlane.Worker.csproj index 839d0ec..722aaf2 100644 --- a/ControlPlane.Worker/ControlPlane.Worker.csproj +++ b/ControlPlane.Worker/ControlPlane.Worker.csproj @@ -6,6 +6,7 @@ + diff --git a/ControlPlane.Worker/Dockerfile b/ControlPlane.Worker/Dockerfile index 78adb66..b1e780f 100644 --- a/ControlPlane.Worker/Dockerfile +++ b/ControlPlane.Worker/Dockerfile @@ -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"] diff --git a/ControlPlane.Worker/Program.cs b/ControlPlane.Worker/Program.cs index 8d17846..5b86b47 100644 --- a/ControlPlane.Worker/Program.cs +++ b/ControlPlane.Worker/Program.cs @@ -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(); +// 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(); + // Docker container manager for per-tenant Clarity.Server instances builder.Services.AddSingleton(); @@ -44,6 +55,7 @@ builder.Services.AddMassTransit(x => x.SetKebabCaseEndpointNameFormatter(); x.AddConsumer(); + x.AddConsumer(); x.UsingRabbitMq((ctx, cfg) => { diff --git a/ControlPlane.Worker/appsettings.Development.json b/ControlPlane.Worker/appsettings.Development.json index 57cea3c..e937417 100644 --- a/ControlPlane.Worker/appsettings.Development.json +++ b/ControlPlane.Worker/appsettings.Development.json @@ -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" } } diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..4fbd04e --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + false + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..e8151c1 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,52 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 36bc3ca..489bf5f 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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: diff --git a/scripts/create-external-volumes.ps1 b/scripts/create-external-volumes.ps1 new file mode 100644 index 0000000..d1fc91b --- /dev/null +++ b/scripts/create-external-volumes.ps1 @@ -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_" +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."