OPC # 0002: Improvements to Client provisioning workflows

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
amadzarak
2026-04-25 21:57:42 -04:00
parent 35fe82d225
commit 378daf98d6
5 changed files with 501 additions and 77 deletions
@@ -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);
}
+10 -3
View File
@@ -14,8 +14,9 @@ public static class InfraEndpoints
g.MapPost("/{container}/start", (string container) => ServiceAction(container, "start"));
g.MapPost("/{container}/stop", (string container) => ServiceAction(container, "stop"));
g.MapPost("/{container}/restart",(string container) => ServiceAction(container, "restart"));
g.MapGet ("/compose/up/stream", ComposeUpStream);
g.MapGet ("/compose/down/stream", ComposeDownStream);
g.MapGet ("/compose/up/stream", ComposeUpStream);
g.MapGet ("/compose/up-force/stream", ComposeUpForceStream);
g.MapGet ("/compose/down/stream", ComposeDownStream);
return app;
}
@@ -121,8 +122,14 @@ public static class InfraEndpoints
: Results.Problem(output ?? "Docker command failed", statusCode: 500);
}
// Starts all platform services; --remove-orphans cleans up containers with stale names
// (e.g. a leftover clarity-dnsmasq that causes the "name already in use" conflict).
private static Task ComposeUpStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
StreamComposeOutput(ctx, config, "up --pull missing", ct);
StreamComposeOutput(ctx, config, "up -d --remove-orphans", ct);
// Force-recreates every container regardless of config drift — use after image or compose changes.
private static Task ComposeUpForceStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
StreamComposeOutput(ctx, config, "up -d --force-recreate --remove-orphans", ct);
private static Task ComposeDownStream(HttpContext ctx, IConfiguration config, CancellationToken ct) =>
StreamComposeOutput(ctx, config, "down", ct);