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