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 GetStatus() { var services = new List(); 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(); 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 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( 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 Ports, string? Uptime); public record InfraStatusResponse( List Services, DateTimeOffset CheckedAt); }