Files
2026-04-26 00:26:56 -04:00

219 lines
9.0 KiB
C#

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;
}
/// <summary>Returns the last known build status without triggering a new build.</summary>
private static async Task<IResult> GetStatus(ImageBuildService svc) =>
Results.Ok(await svc.GetStatusAsync());
/// <summary>Returns recent DockerImage build records for the sparkline chart.</summary>
private static async Task<IResult> 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);
}
/// <summary>
/// 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.
/// </summary>
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<T> callback (sync) can safely hand lines
// to the async SSE writer without blocking the Docker build thread.
var channel = System.Threading.Channels.Channel.CreateUnbounded<string?>(
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);
/// <summary>
/// Returns the ExtraHosts list for a running tenant container.
/// e.g. GET /api/image/verify/extra-hosts/fdev-app-clarity-01000001
/// </summary>
private static async Task<IResult> 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() });
}
}
/// <summary>
/// Runs <c>curl</c> from inside the container to verify *.clarity.test DNS resolves through nginx.
/// POST /api/image/verify/dns-test body: { container, url }
/// </summary>
private static async Task<IResult> 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(),
});
}
/// <summary>
/// Reads the generated docker-compose.yml from ClientAssets/{subdomain}/.
/// GET /api/image/artifact/{subdomain}
/// </summary>
private static async Task<IResult> 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);
}