OPC # 0001: Extract OPC into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:42 -04:00
commit 42383bdc03
170 changed files with 21365 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
using ControlPlane.Core.Models;
using LibGit2Sharp;
namespace ControlPlane.Api.Endpoints;
public static class GitEndpoints
{
public static IEndpointRouteBuilder MapGitEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/git/log", GetLog);
app.MapGet("/api/git/commits/{hash}", GetCommit);
app.MapGet("/api/git/branches", GetBranches);
app.MapGet("/api/git/branch-coverage", GetBranchCoverage);
return app;
}
// GET /api/git/log?grep=OPC+%23+0001&limit=50
private static IResult GetLog(
IConfiguration config,
string? grep = null,
int limit = 50)
{
var repoPath = ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository. Set Git:RepoRoot in appsettings.");
using var repo = new Repository(repoPath);
var tips = repo.Branches
.Where(b => b.Tip != null)
.Select(b => (GitObject)b.Tip)
.ToList();
var filter = new CommitFilter
{
SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time,
IncludeReachableFrom = tips.Count > 0 ? tips : (object)repo.Head,
};
IEnumerable<Commit> query = repo.Commits.QueryBy(filter);
if (!string.IsNullOrWhiteSpace(grep))
query = query.Where(c => c.Message.Contains(grep, StringComparison.OrdinalIgnoreCase));
var commits = query
.Take(limit)
.Select(c => ToGitCommit(repo, c))
.ToList();
return Results.Ok(commits);
}
// GET /api/git/commits/{hash}
private static IResult GetCommit(string hash, IConfiguration config)
{
var repoPath = ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository.");
using var repo = new Repository(repoPath);
var commit = repo.Lookup<Commit>(hash);
if (commit is null) return Results.NotFound();
var parentTree = commit.Parents.FirstOrDefault()?.Tree;
var changes = repo.Diff.Compare<TreeChanges>(parentTree, commit.Tree);
var patch = repo.Diff.Compare<Patch>(parentTree, commit.Tree);
var files = changes.Select(c => new
{
path = c.Path,
oldPath = c.OldPath,
status = c.Status.ToString(),
additions = patch[c.Path]?.LinesAdded ?? 0,
deletions = patch[c.Path]?.LinesDeleted ?? 0,
patch = patch[c.Path]?.Patch ?? string.Empty,
}).ToList();
return Results.Ok(new
{
hash = commit.Sha,
shortHash = commit.Sha[..7],
author = commit.Author.Name,
email = commit.Author.Email,
date = commit.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
subject = commit.MessageShort,
body = commit.Message,
files,
});
}
// GET /api/git/branches
private static IResult GetBranches(IConfiguration config)
{
var repoPath = ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository.");
using var repo = new Repository(repoPath);
var branches = repo.Branches
.Where(b => !b.IsRemote && b.Tip != null)
.Select(b => new
{
name = b.FriendlyName,
hash = b.Tip.Sha,
shortHash = b.Tip.Sha[..7],
subject = b.Tip.MessageShort,
author = b.Tip.Author.Name,
date = b.Tip.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
isHead = b.IsCurrentRepositoryHead,
})
.OrderBy(b => b.name)
.ToList();
return Results.Ok(branches);
}
// GET /api/git/branch-coverage?commits=hash1,hash2,hash3
// Returns each local branch and whether it contains ALL of the given commits.
private static IResult GetBranchCoverage(IConfiguration config, string? commits = null)
{
if (string.IsNullOrWhiteSpace(commits)) return Results.Ok(Array.Empty<object>());
var hashes = commits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (hashes.Length == 0) return Results.Ok(Array.Empty<object>());
var repoPath = ResolveRepo(config);
if (repoPath is null)
return Results.Problem("Could not locate a git repository.");
using var repo = new Repository(repoPath);
var targetCommits = hashes
.Select(h => repo.Lookup<Commit>(h))
.Where(c => c is not null)
.ToList();
if (targetCommits.Count == 0) return Results.Ok(Array.Empty<object>());
var result = repo.Branches
.Where(b => !b.IsRemote && b.Tip != null)
.Select(b =>
{
var contains = targetCommits.All(tc =>
{
// If merge base of branch tip and target == target, then target is an ancestor
var mergeBase = repo.ObjectDatabase.FindMergeBase(b.Tip, tc!);
return mergeBase?.Sha == tc!.Sha;
});
return new
{
branch = b.FriendlyName,
contains,
tipHash = b.Tip.Sha[..7],
isHead = b.IsCurrentRepositoryHead,
};
})
.OrderBy(b => b.branch)
.ToList();
return Results.Ok(result);
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Resolves the repo root: explicit config overrides, otherwise auto-discover
/// from the running assembly directory upward via LibGit2Sharp.
private static string? ResolveRepo(IConfiguration config)
{
var configured = config["Git:RepoRoot"] ?? config["Docker:RepoRoot"];
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
return configured;
// Auto-discover: walk up from the app's own directory
var startPath = AppContext.BaseDirectory;
var discovered = Repository.Discover(startPath);
if (discovered is null) return null;
// Repository.Discover returns the .git directory path; get the working dir
using var probe = new Repository(discovered);
return probe.Info.WorkingDirectory;
}
private static GitCommit ToGitCommit(Repository repo, Commit c)
{
string[] files;
try
{
var parentTree = c.Parents.FirstOrDefault()?.Tree;
var changes = repo.Diff.Compare<TreeChanges>(parentTree, c.Tree);
files = changes.Select(ch => ch.Path).ToArray();
}
catch { files = []; }
return new GitCommit(
Hash: c.Sha,
ShortHash: c.Sha[..7],
Author: c.Author.Name,
Date: c.Author.When.ToString("yyyy-MM-dd HH:mm:ss zzz"),
Subject: c.MessageShort,
Files: files
);
}
}
@@ -0,0 +1,79 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Models;
namespace ControlPlane.Api.Endpoints;
public static class GiteaEndpoints
{
public static IEndpointRouteBuilder MapGiteaEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/gitea").WithTags("Gitea");
g.MapGet ("/repo", GetRepo);
g.MapGet ("/branches", ListBranches);
g.MapPost("/branches", CreateBranch);
g.MapGet ("/pulls", ListPulls);
g.MapGet ("/pulls/{number:long}", GetPull);
g.MapPost("/pulls", CreatePull);
g.MapGet ("/tags", ListTags);
g.MapPost("/tags", CreateTag);
g.MapGet ("/webhooks", ListWebhooks);
g.MapPost("/webhooks", RegisterWebhook);
return app;
}
private static async Task<IResult> GetRepo(GiteaService svc, CancellationToken ct)
{
var repo = await svc.GetRepoAsync(ct);
return repo is null ? Results.StatusCode(503) : Results.Ok(repo);
}
private static async Task<IResult> ListBranches(GiteaService svc, CancellationToken ct) =>
Results.Ok(await svc.ListBranchesAsync(ct));
private static async Task<IResult> CreateBranch(
CreateBranchRequest req, GiteaService svc, CancellationToken ct)
{
var branch = await svc.CreateBranchAsync(req, ct);
return branch is null ? Results.BadRequest("Failed to create branch in Gitea.") : Results.Ok(branch);
}
private static async Task<IResult> ListPulls(
GiteaService svc, string state = "open", CancellationToken ct = default) =>
Results.Ok(await svc.ListPullRequestsAsync(state, ct));
private static async Task<IResult> GetPull(
long number, GiteaService svc, CancellationToken ct)
{
var pr = await svc.GetPullRequestAsync(number, ct);
return pr is null ? Results.NotFound() : Results.Ok(pr);
}
private static async Task<IResult> CreatePull(
CreatePullRequestRequest req, GiteaService svc, CancellationToken ct)
{
var pr = await svc.CreatePullRequestAsync(req, ct);
return pr is null ? Results.BadRequest("Failed to create PR in Gitea.") : Results.Ok(pr);
}
private static async Task<IResult> ListTags(GiteaService svc, CancellationToken ct) =>
Results.Ok(await svc.ListTagsAsync(ct));
private static async Task<IResult> CreateTag(
CreateTagRequest req, GiteaService svc, CancellationToken ct)
{
var tag = await svc.CreateTagAsync(req, ct);
return tag is null ? Results.BadRequest("Failed to create tag in Gitea.") : Results.Ok(tag);
}
private static async Task<IResult> ListWebhooks(GiteaService svc, CancellationToken ct) =>
Results.Ok(await svc.ListWebhooksAsync(ct));
private static async Task<IResult> RegisterWebhook(
CreateWebhookRequest req, GiteaService svc, CancellationToken ct)
{
var hook = await svc.RegisterWebhookAsync(req, ct);
return hook is null ? Results.BadRequest("Failed to register webhook in Gitea.") : Results.Ok(hook);
}
}
@@ -0,0 +1,75 @@
using ControlPlane.Api.Services;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class ImageBuildEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public static IEndpointRouteBuilder MapImageBuildEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/image").WithTags("Image");
group.MapGet("/status", GetStatus);
group.MapPost("/build", TriggerBuild);
return app;
}
/// <summary>Returns the last known build status without triggering a new build.</summary>
private static async Task<IResult> GetStatus(ImageBuildService svc) =>
Results.Ok(await svc.GetStatusAsync());
/// <summary>
/// Triggers a docker build and streams the output line-by-line as SSE.
/// The build context is the repo root, which must be configured via
/// Docker:RepoRoot in appsettings / environment.
/// </summary>
private static async Task TriggerBuild(
HttpContext ctx,
ImageBuildService svc,
IConfiguration config,
CancellationToken ct)
{
var repoRoot = config["Docker:RepoRoot"];
if (string.IsNullOrWhiteSpace(repoRoot) || !Directory.Exists(repoRoot))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(new
{
error = "Docker:RepoRoot is not configured or does not exist.",
hint = "Add Docker__RepoRoot to the worker environment pointing at the repo root directory.",
}, ct);
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
// Use a Channel so the Progress<T> callback (sync) can safely hand lines
// to the async SSE writer without blocking the Docker build thread.
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true });
void OnLine(string line) => channel.Writer.TryWrite(line);
// Run the build on a background thread so we can drain the channel here
var buildTask = Task.Run(() => svc.BuildAsync(repoRoot, OnLine, ct), ct)
.ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default);
await foreach (var line in channel.Reader.ReadAllAsync(ct))
{
var json = JsonSerializer.Serialize(new { line }, JsonOpts);
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
await buildTask; // ensure build is fully done
// Signal stream end
await ctx.Response.WriteAsync("data: {\"done\":true}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
@@ -0,0 +1,232 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ControlPlane.Api.Endpoints;
public static class InfraEndpoints
{
public static IEndpointRouteBuilder MapInfraEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/infra").WithTags("Infrastructure");
g.MapGet ("/status", GetStatus);
g.MapPost("/{container}/start", (string container) => ServiceAction(container, "start"));
g.MapPost("/{container}/stop", (string container) => ServiceAction(container, "stop"));
g.MapPost("/{container}/restart",(string container) => ServiceAction(container, "restart"));
g.MapGet ("/compose/up/stream", ComposeUpStream);
g.MapGet ("/compose/down/stream", ComposeDownStream);
return app;
}
// ── Known platform services ───────────────────────────────────────────────
private static readonly string[] PlatformContainers =
[
"clarity-postgres",
"clarity-keycloak",
"clarity-vault",
"clarity-minio",
"clarity-gitea",
"clarity-nginx",
"clarity-dnsmasq",
];
// ── Handlers ─────────────────────────────────────────────────────────────
private static async Task<IResult> GetStatus()
{
var services = new List<InfraService>();
foreach (var container in PlatformContainers)
{
var (code, output) = await DockerAsync(
$"inspect --format={{{{json .}}}} {container}");
if (code != 0 || string.IsNullOrWhiteSpace(output))
{
services.Add(new InfraService(container, container, "stopped", [], null));
continue;
}
try
{
using var doc = JsonDocument.Parse(output.Trim());
var root = doc.RootElement;
var state = root.GetProperty("State").GetProperty("Status").GetString() ?? "unknown";
var health = root.GetProperty("State").TryGetProperty("Health", out var h)
? h.GetProperty("Status").GetString()
: null;
var status = (state, health) switch
{
("running", "unhealthy") => "unhealthy",
("running", _) => "running",
("exited", _) => "stopped",
_ => state
};
// Ports
var ports = new List<string>();
if (root.TryGetProperty("NetworkSettings", out var ns) &&
ns.TryGetProperty("Ports", out var portsEl))
{
foreach (var port in portsEl.EnumerateObject())
{
if (port.Value.ValueKind != JsonValueKind.Null)
ports.Add(port.Name.Split('/')[0]);
}
}
// Uptime
string? uptime = null;
if (root.GetProperty("State").TryGetProperty("StartedAt", out var startedAt))
{
if (DateTime.TryParse(startedAt.GetString(), out var started) && state == "running")
{
var elapsed = DateTime.UtcNow - started.ToUniversalTime();
uptime = elapsed.TotalDays >= 1
? $"{(int)elapsed.TotalDays}d {elapsed.Hours}h"
: elapsed.TotalHours >= 1
? $"{(int)elapsed.TotalHours}h {elapsed.Minutes}m"
: $"{elapsed.Minutes}m";
}
}
// Friendly name
var name = root.TryGetProperty("Name", out var n)
? n.GetString()?.TrimStart('/') ?? container
: container;
services.Add(new InfraService(name, container, status, ports, uptime));
}
catch
{
services.Add(new InfraService(container, container, "unknown", [], null));
}
}
return Results.Ok(new InfraStatusResponse(services, DateTimeOffset.UtcNow));
}
private static async Task<IResult> ServiceAction(string container, string action)
{
if (!PlatformContainers.Contains(container))
return Results.BadRequest($"Unknown platform container: {container}");
var (code, output) = await DockerAsync($"{action} {container}");
return code == 0
? Results.Ok()
: Results.Problem(output ?? "Docker command failed", statusCode: 500);
}
private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
StreamComposeOutput(ctx, config, "up --pull missing", ct);
private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
StreamComposeOutput(ctx, config, "down", ct);
private static async Task StreamComposeOutput(
HttpContext ctx, IConfiguration config, string composeArgs, CancellationToken ct)
{
var infraDir = ResolveInfraPath(config);
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = false, SingleReader = true });
var psi = new ProcessStartInfo("docker",
$"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {composeArgs}")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = infraDir,
};
var proc = Process.Start(psi)!;
// Read stdout + stderr concurrently into the channel
var stdoutTask = Task.Run(async () =>
{
while (await proc.StandardOutput.ReadLineAsync(ct) is { } line)
channel.Writer.TryWrite(line);
}, ct);
var stderrTask = Task.Run(async () =>
{
while (await proc.StandardError.ReadLineAsync(ct) is { } line)
channel.Writer.TryWrite(line);
}, ct);
_ = Task.WhenAll(stdoutTask, stderrTask)
.ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default);
// Stream lines to client as SSE
await foreach (var line in channel.Reader.ReadAllAsync(ct))
{
if (line is null) continue;
await ctx.Response.WriteAsync($"data: {line}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
await proc.WaitForExitAsync(ct);
var exitLine = proc.ExitCode == 0 ? "data: ✔ Done." : $"data: ✖ Exited with code {proc.ExitCode}";
await ctx.Response.WriteAsync($"{exitLine}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
proc.Dispose();
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static string ResolveInfraPath(IConfiguration config)
{
var repoRoot = config["Docker:RepoRoot"]
?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
return Path.GetFullPath(Path.Combine(repoRoot, "infra"));
}
private static Task<(int Code, string? Output)> DockerAsync(string args) =>
RunAsync("docker", args, null);
private static async Task<(int Code, string? Output)> ComposeAsync(string args, string infraDir)=>
await RunAsync("docker", $"compose -f \"{Path.Combine(infraDir, "docker-compose.yml")}\" {args}", infraDir);
private static async Task<(int Code, string? Output)> RunAsync(
string exe, string args, string? workingDir)
{
var psi = new ProcessStartInfo(exe, args)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
if (workingDir is not null) psi.WorkingDirectory = workingDir;
using var proc = Process.Start(psi);
if (proc is null) return (-1, null);
var output = await proc.StandardOutput.ReadToEndAsync();
var err = await proc.StandardError.ReadToEndAsync();
await proc.WaitForExitAsync();
return (proc.ExitCode, string.IsNullOrWhiteSpace(output) ? err : output);
}
// ── Response models ───────────────────────────────────────────────────────
public record InfraService(
string Name,
string Container,
string Status,
List<string> Ports,
string? Uptime);
public record InfraStatusResponse(
List<InfraService> Services,
DateTimeOffset CheckedAt);
}
+244
View File
@@ -0,0 +1,244 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Models;
using LibGit2Sharp;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class OpcEndpoints
{
private static readonly JsonSerializerOptions JsonOpts =
new(JsonSerializerDefaults.Web) { WriteIndented = false };
public static IEndpointRouteBuilder MapOpcEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/opc").WithTags("OPC");
// ── OPC CRUD ──────────────────────────────────────────────────────────
g.MapGet ("", ListOpcs);
g.MapGet ("/next-number", GetNextNumber);
g.MapPost ("", CreateOpc);
g.MapGet ("/{id:guid}", GetOpc);
g.MapPatch ("/{id:guid}", UpdateOpc);
g.MapDelete("/{id:guid}", DeleteOpc);
// ── Notes ─────────────────────────────────────────────────────────────
g.MapGet ("/{id:guid}/notes", ListNotes);
g.MapPost ("/{id:guid}/notes", AddNote);
// ── Artifacts ─────────────────────────────────────────────────────────
g.MapGet ("/{id:guid}/artifacts", ListArtifacts);
g.MapPost ("/{id:guid}/artifacts", CreateArtifact);
g.MapPatch ("/artifacts/{artifactId:guid}", UpdateArtifact);
g.MapDelete("/artifacts/{artifactId:guid}", DeleteArtifact);
// ── Pinned commits ────────────────────────────────────────────────────
g.MapGet ("/{id:guid}/pinned-commits", ListPinnedCommits);
g.MapPost ("/{id:guid}/pinned-commits", PinCommit);
g.MapDelete("/{id:guid}/pinned-commits/{hash}", UnpinCommit);
// ── AI assist (proxies to OpenRouter, key stays on server) ────────────
g.MapPost ("/ai-assist", AiAssist);
return app;
}
// ── OPC handlers ──────────────────────────────────────────────────────────
private static async Task<IResult> ListOpcs(
OpcService svc,
string? type = null, string? status = null,
CancellationToken ct = default)
{
var list = await svc.ListAsync(type, status, ct);
return Results.Ok(list);
}
private static async Task<IResult> GetNextNumber(
OpcService svc, CancellationToken ct)
{
var number = await svc.NextNumberAsync(ct);
return Results.Ok(new { number });
}
private static async Task<IResult> CreateOpc(
CreateOpcRequest req, OpcService svc, CancellationToken ct)
{
var opc = await svc.CreateAsync(req, ct);
return Results.Created($"/api/opc/{opc.Id}", opc);
}
private static async Task<IResult> GetOpc(
Guid id, OpcService svc, CancellationToken ct)
{
var opc = await svc.GetAsync(id, ct);
return opc is null ? Results.NotFound() : Results.Ok(opc);
}
private static async Task<IResult> UpdateOpc(
Guid id, UpdateOpcRequest req, OpcService svc, CancellationToken ct)
{
var opc = await svc.UpdateAsync(id, req, ct);
return opc is null ? Results.NotFound() : Results.Ok(opc);
}
private static async Task<IResult> DeleteOpc(
Guid id, OpcService svc, CancellationToken ct)
{
return await svc.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound();
}
// ── Note handlers ─────────────────────────────────────────────────────────
private static async Task<IResult> ListNotes(
Guid id, OpcService svc, CancellationToken ct)
{
var notes = await svc.ListNotesAsync(id, ct);
return Results.Ok(notes);
}
private static async Task<IResult> AddNote(
Guid id, AddNoteRequest req, OpcService svc, CancellationToken ct)
{
var note = await svc.AddNoteAsync(id, req, ct);
return Results.Created($"/api/opc/{id}/notes/{note.Id}", note);
}
// ── Artifact handlers ─────────────────────────────────────────────────────
private static async Task<IResult> ListArtifacts(
Guid id, OpcService svc,
string? type = null, CancellationToken ct = default)
{
var artifacts = await svc.ListArtifactsAsync(id, type, ct);
return Results.Ok(artifacts);
}
private static async Task<IResult> CreateArtifact(
Guid id, UpsertArtifactRequest req, OpcService svc, CancellationToken ct)
{
var artifact = await svc.UpsertArtifactAsync(id, req, ct);
return Results.Created($"/api/opc/{id}/artifacts/{artifact.Id}", artifact);
}
private static async Task<IResult> UpdateArtifact(
Guid artifactId, UpsertArtifactRequest req, OpcService svc, CancellationToken ct)
{
var artifact = await svc.UpdateArtifactAsync(artifactId, req, ct);
return artifact is null ? Results.NotFound() : Results.Ok(artifact);
}
private static async Task<IResult> DeleteArtifact(
Guid artifactId, OpcService svc, CancellationToken ct)
{
return await svc.DeleteArtifactAsync(artifactId, ct)
? Results.NoContent()
: Results.NotFound();
}
// ── Pinned commit handlers ────────────────────────────────────────────────
private static async Task<IResult> ListPinnedCommits(
Guid id, OpcService svc, CancellationToken ct)
{
var commits = await svc.ListPinnedCommitsAsync(id, ct);
return Results.Ok(commits);
}
private static async Task<IResult> PinCommit(
Guid id, PinCommitRequest req, OpcService svc, IConfiguration config, CancellationToken ct)
{
var repoPath = config["Docker:RepoRoot"];
string fullHash = req.Hash;
string shortHash = req.Hash.Length >= 7 ? req.Hash[..7] : req.Hash;
string subject = string.Empty;
string author = string.Empty;
if (!string.IsNullOrWhiteSpace(repoPath) && Directory.Exists(repoPath))
{
using var repo = new Repository(repoPath);
var commit = repo.Lookup<Commit>(req.Hash);
if (commit is null) return Results.NotFound("Commit not found in repository.");
fullHash = commit.Sha;
shortHash = commit.Sha[..7];
subject = commit.MessageShort;
author = commit.Author.Name;
}
var pinned = await svc.PinCommitAsync(id, fullHash, shortHash, subject, author, req.PinnedBy, ct);
return pinned is null
? Results.NotFound()
: Results.Created($"/api/opc/{id}/pinned-commits/{fullHash}", pinned);
}
private static async Task<IResult> UnpinCommit(
Guid id, string hash, OpcService svc, CancellationToken ct)
{
return await svc.UnpinCommitAsync(id, hash, ct) ? Results.NoContent() : Results.NotFound();
}
// ── AI assist ─────────────────────────────────────────────────────────────
private static async Task<IResult> AiAssist(
AiAssistRequest req,
IConfiguration config,
IHttpClientFactory http,
CancellationToken ct)
{
var apiKey = config["OpenRouter:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey))
return Results.Problem("OpenRouter API key not configured. Add OpenRouter:ApiKey to appsettings.");
var systemPrompt =
"You are an assistant helping a software engineering team write clear, concise " +
"OPC (Online Project Communication) content — requirements, change descriptions, " +
"QA test paths, and specifications. Be direct, structured, and professional. " +
"Respond with plain text only (no markdown wrapping).";
var messages = new List<object>
{
new { role = "system", content = systemPrompt },
};
if (!string.IsNullOrWhiteSpace(req.Context))
messages.Add(new { role = "user", content = $"Context:\n{req.Context}" });
messages.Add(new { role = "user", content = req.Prompt });
var body = new
{
model = "anthropic/claude-3.5-haiku",
messages,
max_tokens = 1024,
};
var client = http.CreateClient("openrouter");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
client.DefaultRequestHeaders.Add("HTTP-Referer", "https://controlplane.clarity.internal");
client.DefaultRequestHeaders.Add("X-Title", "Clarity ControlPlane OPC");
var response = await client.PostAsync(
"https://openrouter.ai/api/v1/chat/completions",
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
ct);
if (!response.IsSuccessStatusCode)
{
var err = await response.Content.ReadAsStringAsync(ct);
return Results.Problem($"OpenRouter error {response.StatusCode}: {err}");
}
var json = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
var text = doc.RootElement
.GetProperty("choices")[0]
.GetProperty("message")
.GetProperty("content")
.GetString() ?? string.Empty;
return Results.Ok(new { text });
}
}
@@ -0,0 +1,65 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Services;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class ProjectBuildEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
public static IEndpointRouteBuilder MapProjectBuildEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/builds").WithTags("Builds");
group.MapGet("/projects", GetProjects);
group.MapGet("/history", GetHistory);
group.MapPost("/{projectName}", TriggerProjectBuild);
return app;
}
/// <summary>Returns the list of known projects the build monitor can track.</summary>
private static IResult GetProjects(ProjectBuildService svc) =>
Results.Ok(svc.GetProjects());
private static async Task<IResult> GetHistory(BuildHistoryService history) =>
Results.Ok(await history.GetBuildsAsync());
/// <summary>
/// Triggers a build for a named project and streams SSE output.
/// projectName must match one of the names returned by GET /api/builds/projects.
/// </summary>
private static async Task TriggerProjectBuild(
string projectName,
HttpContext ctx,
ProjectBuildService svc,
CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(svc.RepoRoot))
{
ctx.Response.StatusCode = 503;
await ctx.Response.WriteAsJsonAsync(
new { error = "Docker:RepoRoot is not configured on the server." }, ct);
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
async Task Send(object payload)
{
var json = JsonSerializer.Serialize(payload, JsonOpts);
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult();
var record = await svc.BuildProjectAsync(projectName, OnLine, ct);
await Send(new { done = true, build = record });
}
}
@@ -0,0 +1,73 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Models;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class PromotionEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public static IEndpointRouteBuilder MapPromotionEndpoints(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/api/promotions").WithTags("Promotions");
// GET /api/promotions/ladder — branch status for all 4 ladder branches
g.MapGet("/ladder", async (PromotionService svc, CancellationToken ct) =>
Results.Ok(await svc.GetLadderStatusAsync(ct)));
// GET /api/promotions/history
g.MapGet("/history", async (PromotionService svc) =>
Results.Ok(await svc.GetHistoryAsync()));
// POST /api/promotions/promote — body: { from, to, requestedBy, note }
// Streams SSE log lines then sends {done, promotion} when complete
g.MapPost("/promote", async (
HttpContext ctx,
PromotionService svc,
PromoteRequest req,
CancellationToken ct) =>
{
// Validate ladder step
var ladder = PromotionService.Ladder;
var fi = Array.IndexOf(ladder, req.From);
var ti = Array.IndexOf(ladder, req.To);
if (fi < 0 || ti < 0 || ti != fi + 1)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(
new { error = $"Invalid promotion step: {req.From} → {req.To}. Must be adjacent in ladder." }, ct);
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true });
void OnLine(string line) => channel.Writer.TryWrite(line);
var promoteTask = Task.Run(() =>
svc.PromoteAsync(req.From, req.To, req.RequestedBy ?? "system", req.Note, OnLine, ct), ct)
.ContinueWith(t => channel.Writer.TryComplete(t.Exception), TaskScheduler.Default);
await foreach (var line in channel.Reader.ReadAllAsync(ct))
{
var json = JsonSerializer.Serialize(new { line }, JsonOpts);
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
var promotion = await promoteTask;
var doneJson = JsonSerializer.Serialize(new { done = true, promotion }, JsonOpts);
await ctx.Response.WriteAsync($"data: {doneJson}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
});
return app;
}
}
public record PromoteRequest(string From, string To, string? RequestedBy, string? Note);
@@ -0,0 +1,106 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Messages;
using ControlPlane.Core.Models;
using ControlPlane.Core.Services;
using MassTransit;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class ProvisioningEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
public static IEndpointRouteBuilder MapProvisioningEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/provision").WithTags("Provisioning");
group.MapPost("/", QueueProvisioningJob);
group.MapGet("/{id:guid}", GetJobStatus);
group.MapGet("/{id:guid}/stream", StreamJobEvents);
app.MapGet("/api/tenants", GetTenants).WithTags("Tenants");
return app;
}
private static async Task<IResult> QueueProvisioningJob(
ProvisioningRequest request,
Dictionary<Guid, ProvisioningJob> jobs,
IPublishEndpoint bus)
{
var job = new ProvisioningJob
{
ClientName = request.ClientName,
StateCode = request.StateCode.ToUpperInvariant(),
Subdomain = request.Subdomain,
AdminEmail = request.AdminEmail,
SiteCode = request.SiteCode,
Environment = request.Environment,
Tier = request.Tier,
Status = ProvisioningStatus.Pending
};
jobs[job.Id] = job;
await bus.Publish(new ProvisionClientCommand
{
JobId = job.Id,
ClientName = job.ClientName,
StateCode = job.StateCode,
Subdomain = job.Subdomain,
AdminEmail = job.AdminEmail,
SiteCode = job.SiteCode,
Environment = job.Environment,
Tier = job.Tier
});
return Results.Accepted($"/api/provision/{job.Id}", new { job.Id, job.Status });
}
private static IResult GetJobStatus(Guid id, Dictionary<Guid, ProvisioningJob> jobs) =>
jobs.TryGetValue(id, out var job) ? Results.Ok(job) : Results.NotFound();
private static IResult GetTenants(TenantRegistryService registry) =>
Results.Ok(registry.GetAll());
private static async Task StreamJobEvents(
Guid id,
SseEventBus bus,
Dictionary<Guid, ProvisioningJob> jobs,
HttpContext ctx,
CancellationToken cancellationToken)
{
if (!jobs.ContainsKey(id))
{
ctx.Response.StatusCode = 404;
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
var channel = bus.Subscribe(id);
try
{
await foreach (var evt in channel.Reader.ReadAllAsync(cancellationToken))
{
var json = JsonSerializer.Serialize(evt, JsonOpts);
await ctx.Response.WriteAsync($"data: {json}\n\n", cancellationToken);
await ctx.Response.Body.FlushAsync(cancellationToken);
if (evt.Type is "job_complete" or "job_failed") break;
}
}
catch (OperationCanceledException)
{
// Client disconnected (e.g. browser refresh) — not an error.
}
finally
{
bus.Unsubscribe(id, channel);
}
}
}
@@ -0,0 +1,62 @@
using ControlPlane.Api.Services;
using ControlPlane.Core.Services;
using System.Text.Json;
namespace ControlPlane.Api.Endpoints;
public static class ReleaseEndpoints
{
private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web)
{
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
};
public static IEndpointRouteBuilder MapReleaseEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/release").WithTags("Release");
group.MapGet("/history", GetHistory);
group.MapPost("/{env}", TriggerRelease);
return app;
}
private static async Task<IResult> GetHistory(BuildHistoryService history) =>
Results.Ok(await history.GetReleasesAsync());
/// <summary>
/// Triggers a rolling redeploy of all managed containers in the target env.
/// Streams SSE lines until release is complete.
/// Valid env values: fdev | uat | prod | all
/// </summary>
private static async Task TriggerRelease(
string env,
HttpContext ctx,
ReleaseService releases,
CancellationToken ct)
{
var valid = new[] { "fdev", "uat", "prod", "all" };
if (!valid.Contains(env, StringComparer.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsJsonAsync(
new { error = $"Invalid environment '{env}'. Valid: fdev, uat, prod, all." }, ct);
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
async Task Send(object payload)
{
var json = JsonSerializer.Serialize(payload, JsonOpts);
await ctx.Response.WriteAsync($"data: {json}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
void OnLine(string line) => Send(new { line }).GetAwaiter().GetResult();
var record = await releases.ReleaseAsync(env, OnLine, ct);
await Send(new { done = true, release = record });
}
}
@@ -0,0 +1,108 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using ControlPlane.Core.Services;
namespace ControlPlane.Api.Endpoints;
public static class TenantLogEndpoints
{
public static IEndpointRouteBuilder MapTenantLogEndpoints(this IEndpointRouteBuilder app)
{
app.MapGet("/api/tenants/{subdomain}/logs", StreamTenantLogs).WithTags("Tenants");
return app;
}
private static async Task StreamTenantLogs(
string subdomain,
IConfiguration config,
TenantRegistryService registry,
HttpContext ctx,
CancellationToken cancellationToken)
{
var tenant = registry.GetAll().FirstOrDefault(t => t.Subdomain == subdomain);
if (tenant is null)
{
ctx.Response.StatusCode = 404;
return;
}
var containerName = tenant.ContainerName;
if (string.IsNullOrWhiteSpace(containerName))
{
ctx.Response.StatusCode = 404;
return;
}
ctx.Response.Headers.ContentType = "text/event-stream";
ctx.Response.Headers.CacheControl = "no-cache";
ctx.Response.Headers.Connection = "keep-alive";
var socketUri = config["Docker:Socket"] ?? "npipe://./pipe/docker_engine";
using var docker = new DockerClientConfiguration(new Uri(socketUri)).CreateClient();
var logParams = new ContainerLogsParameters
{
ShowStdout = true,
ShowStderr = true,
Follow = true,
Tail = "200",
Timestamps = true,
};
try
{
using var stream = await docker.Containers.GetContainerLogsAsync(
containerName, tty: false, logParams, cancellationToken);
// MultiplexedStream exposes CopyOutputToAsync which separates stdout/stderr
var stdoutBuf = new System.IO.MemoryStream();
var stderrBuf = new System.IO.MemoryStream();
// Stream with Follow=true won't complete until cancelled — use a pipe instead
var stdoutPipe = new System.IO.Pipelines.Pipe();
var stderrPipe = new System.IO.Pipelines.Pipe();
_ = Task.Run(async () =>
{
try
{
await stream.CopyOutputToAsync(
System.IO.Stream.Null,
stdoutPipe.Writer.AsStream(),
stderrPipe.Writer.AsStream(),
cancellationToken);
}
finally
{
stdoutPipe.Writer.Complete();
stderrPipe.Writer.Complete();
}
}, cancellationToken);
// Merge both pipes into SSE — read stdout line by line
var stdoutReader = new System.IO.StreamReader(stdoutPipe.Reader.AsStream());
var stderrReader = new System.IO.StreamReader(stderrPipe.Reader.AsStream());
var stdoutTask = ReadLinesAsync(stdoutReader, ctx, cancellationToken);
var stderrTask = ReadLinesAsync(stderrReader, ctx, cancellationToken);
await Task.WhenAll(stdoutTask, stderrTask);
}
catch (OperationCanceledException) { /* client disconnected — normal */ }
}
private static async Task ReadLinesAsync(
System.IO.StreamReader reader,
HttpContext ctx,
CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(ct);
if (line is null) break;
if (string.IsNullOrWhiteSpace(line)) continue;
await ctx.Response.WriteAsync($"data: {line}\n\n", ct);
await ctx.Response.Body.FlushAsync(ct);
}
}
}