using System.Diagnostics; using System.Text.Json; using System.Text.RegularExpressions; using ControlPlane.Api.Services; using ControlPlane.Core.Services; namespace ControlPlane.Api.Endpoints; public static class ImageBuildEndpoints { private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web); public static IEndpointRouteBuilder MapImageBuildEndpoints(this IEndpointRouteBuilder app) { var group = app.MapGroup("/api/image").WithTags("Image"); group.MapGet("/status", GetStatus); group.MapGet("/history", GetHistory); group.MapPost("/build", TriggerBuild); // Post-provisioning verification helpers group.MapGet ("/verify/extra-hosts/{containerName}", GetExtraHosts); group.MapPost("/verify/dns-test", DnsTest); group.MapGet ("/artifact/{subdomain}", GetArtifact); return app; } /// Returns the last known build status without triggering a new build. private static async Task GetStatus(ImageBuildService svc) => Results.Ok(await svc.GetStatusAsync()); /// Returns recent DockerImage build records for the sparkline chart. private static async Task GetHistory(BuildHistoryService history, int limit = 30) { var all = await history.GetBuildsAsync(); var records = all .Where(b => b.Kind == ControlPlane.Core.Models.BuildKind.DockerImage) .Take(Math.Clamp(limit, 1, 100)) .Select(b => new { b.Id, b.Status, b.StartedAt, b.DurationMs, b.CommitSha, b.ImageDigest, }) .ToList(); return Results.Ok(records); } /// /// Triggers a docker build and streams the output line-by-line as SSE. /// The build context is the repo root, which must be configured via /// Docker:RepoRoot in appsettings / environment. /// private static async Task TriggerBuild( HttpContext ctx, ImageBuildService svc, IConfiguration config, CancellationToken ct) { var repoRoot = config["Docker:RepoRoot"]; if (string.IsNullOrWhiteSpace(repoRoot) || !Directory.Exists(repoRoot)) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsJsonAsync(new { error = "Docker:RepoRoot is not configured or does not exist.", hint = "Add Docker__RepoRoot to the worker environment pointing at the repo root directory.", }, ct); return; } ctx.Response.Headers.ContentType = "text/event-stream"; ctx.Response.Headers.CacheControl = "no-cache"; ctx.Response.Headers.Connection = "keep-alive"; // Use a Channel so the Progress callback (sync) can safely hand lines // to the async SSE writer without blocking the Docker build thread. var channel = System.Threading.Channels.Channel.CreateUnbounded( new System.Threading.Channels.UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); void OnLine(string line) => channel.Writer.TryWrite(line); // Run the build on a background thread so we can drain the channel here var buildTask = Task.Run(() => svc.BuildAsync(repoRoot, OnLine, ct), ct) .ContinueWith(_ => channel.Writer.TryComplete(), TaskScheduler.Default); await foreach (var line in channel.Reader.ReadAllAsync(ct)) { var json = JsonSerializer.Serialize(new { line }, JsonOpts); await ctx.Response.WriteAsync($"data: {json}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } await buildTask; // ensure build is fully done // Signal stream end await ctx.Response.WriteAsync("data: {\"done\":true}\n\n", ct); await ctx.Response.Body.FlushAsync(ct); } // ── Post-provisioning verification endpoints ────────────────────────────── private static readonly Regex SafeContainerName = new(@"^[a-zA-Z0-9_.\-]+$", RegexOptions.Compiled); /// /// Returns the ExtraHosts list for a running tenant container. /// e.g. GET /api/image/verify/extra-hosts/fdev-app-clarity-01000001 /// private static async Task GetExtraHosts(string containerName) { if (!SafeContainerName.IsMatch(containerName)) return Results.BadRequest(new { error = "Invalid container name." }); var (code, output) = await DockerRunAsync($"inspect --format {{{{json .HostConfig.ExtraHosts}}}} {containerName}"); if (code != 0 || string.IsNullOrWhiteSpace(output)) return Results.NotFound(new { error = $"Container '{containerName}' not found or not running.", detail = output }); try { var hosts = JsonDocument.Parse(output.Trim()).RootElement; return Results.Ok(new { containerName, extraHosts = hosts }); } catch { return Results.Ok(new { containerName, extraHosts = (object?)null, raw = output.Trim() }); } } /// /// Runs curl from inside the container to verify *.clarity.test DNS resolves through nginx. /// POST /api/image/verify/dns-test body: { container, url } /// private static async Task DnsTest(DnsTestRequest body) { if (!SafeContainerName.IsMatch(body.Container)) return Results.BadRequest(new { error = "Invalid container name." }); // Only allow http/https URLs — prevents command injection via url field if (!body.Url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !body.Url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return Results.BadRequest(new { error = "URL must start with http:// or https://." }); var psi = new ProcessStartInfo("docker", $"exec {body.Container} curl -sf --max-time 10 --write-out \"\\nHTTP %{{http_code}}\" {body.Url}") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; using var proc = Process.Start(psi); if (proc is null) return Results.Problem("Failed to start docker process."); var stdout = await proc.StandardOutput.ReadToEndAsync(); var stderr = await proc.StandardError.ReadToEndAsync(); await proc.WaitForExitAsync(); return Results.Ok(new { success = proc.ExitCode == 0, exitCode = proc.ExitCode, output = stdout.Trim(), error = stderr.Trim(), }); } /// /// Reads the generated docker-compose.yml from ClientAssets/{subdomain}/. /// GET /api/image/artifact/{subdomain} /// private static async Task GetArtifact(string subdomain, IConfiguration config) { // Restrict subdomain to safe characters — prevents path traversal if (!SafeContainerName.IsMatch(subdomain)) return Results.BadRequest(new { error = "Invalid subdomain." }); var root = config["ClientAssets__Folder"] ?? config["ClientAssets:Folder"] ?? Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "ClientAssets")); // Use Path.GetFileName to ensure no directory traversal var safeSubdomain = Path.GetFileName(subdomain); var composePath = Path.GetFullPath(Path.Combine(root, safeSubdomain, "docker-compose.yml")); // Verify the final path is still inside the ClientAssets root if (!composePath.StartsWith(Path.GetFullPath(root), StringComparison.OrdinalIgnoreCase)) return Results.BadRequest(new { error = "Invalid subdomain path." }); if (!File.Exists(composePath)) return Results.NotFound(new { error = $"No compose artifact found for '{subdomain}'." }); var content = await File.ReadAllTextAsync(composePath); return Results.Ok(new { subdomain, path = composePath, content }); } private static async Task<(int code, string? output)> DockerRunAsync(string args) { var psi = new ProcessStartInfo("docker", args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; 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); } private record DnsTestRequest(string Container, string Url); }