Files
OPC/ControlPlane.Api/Endpoints/InfraEndpoints.cs
T
2026-04-25 18:05:57 -04:00

233 lines
9.3 KiB
C#

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);
}