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