OPC # 0002: Improvements to Client provisioning workflows
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using ControlPlane.Api.Services;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ControlPlane.Api.Services;
|
||||
|
||||
namespace ControlPlane.Api.Endpoints;
|
||||
|
||||
@@ -11,8 +13,13 @@ public static class ImageBuildEndpoints
|
||||
{
|
||||
var group = app.MapGroup("/api/image").WithTags("Image");
|
||||
|
||||
group.MapGet("/status", GetStatus);
|
||||
group.MapPost("/build", TriggerBuild);
|
||||
group.MapGet("/status", GetStatus);
|
||||
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;
|
||||
}
|
||||
@@ -72,4 +79,118 @@ public static class ImageBuildEndpoints
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user