OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user