db025cce01
Co-authored-by: Copilot <copilot@github.com>
219 lines
9.0 KiB
C#
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);
|
|
}
|