378daf98d6
Co-authored-by: Copilot <copilot@github.com>
240 lines
9.9 KiB
C#
240 lines
9.9 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/up-force/stream", ComposeUpForceStream);
|
|
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);
|
|
}
|
|
|
|
// Starts all platform services; --remove-orphans cleans up containers with stale names
|
|
// (e.g. a leftover clarity-dnsmasq that causes the "name already in use" conflict).
|
|
private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
|
StreamComposeOutput(ctx, config, "up -d --remove-orphans", ct);
|
|
|
|
// Force-recreates every container regardless of config drift — use after image or compose changes.
|
|
private static Task ComposeUpForceStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
|
|
StreamComposeOutput(ctx, config, "up -d --force-recreate --remove-orphans", 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);
|
|
}
|