From 378daf98d653cd8aa28af7e7f67e5abe7024878c Mon Sep 17 00:00:00 2001 From: amadzarak Date: Sat, 25 Apr 2026 21:57:42 -0400 Subject: [PATCH] OPC # 0002: Improvements to Client provisioning workflows Co-authored-by: Copilot --- .../Endpoints/ImageBuildEndpoints.cs | 127 +++++- ControlPlane.Api/Endpoints/InfraEndpoints.cs | 13 +- .../Services/ClarityContainerService.cs | 44 +- clarity.controlplane/src/api/infraApi.ts | 8 + .../src/pages/ImageBuildPage.tsx | 386 +++++++++++++++--- 5 files changed, 501 insertions(+), 77 deletions(-) diff --git a/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs index 9c334f9..6ee8604 100644 --- a/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs +++ b/ControlPlane.Api/Endpoints/ImageBuildEndpoints.cs @@ -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); + + /// + /// 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); } diff --git a/ControlPlane.Api/Endpoints/InfraEndpoints.cs b/ControlPlane.Api/Endpoints/InfraEndpoints.cs index 9bd12e4..1761abc 100644 --- a/ControlPlane.Api/Endpoints/InfraEndpoints.cs +++ b/ControlPlane.Api/Endpoints/InfraEndpoints.cs @@ -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); diff --git a/ControlPlane.Worker/Services/ClarityContainerService.cs b/ControlPlane.Worker/Services/ClarityContainerService.cs index 99231c0..d08cb27 100644 --- a/ControlPlane.Worker/Services/ClarityContainerService.cs +++ b/ControlPlane.Worker/Services/ClarityContainerService.cs @@ -553,13 +553,13 @@ public class ClarityContainerService( // ── Postgres ────────────────────────────────────────────────────────── if (stack.Postgres == ComponentMode.OwnContainer) { - sb.AppendLine($""" - pg-{s}: + sb.AppendLine($$""" + pg-{{s}}: image: postgres:16 restart: unless-stopped environment: POSTGRES_USER: clarity - POSTGRES_PASSWORD: ${{POSTGRES_PASSWORD:-clarity-dev}} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-clarity-dev} POSTGRES_DB: clarity expose: - "5432" @@ -574,7 +574,7 @@ public class ClarityContainerService( - clarity-net labels: clarity.managed: "true" - clarity.subdomain: {s} + clarity.subdomain: {{s}} clarity.component: postgres """); } @@ -591,20 +591,20 @@ public class ClarityContainerService( """ : string.Empty; - sb.AppendLine($""" - kc-{s}: + sb.AppendLine($$""" + kc-{{s}}: image: quay.io/keycloak/keycloak:latest restart: unless-stopped command: start-dev environment: KEYCLOAK_ADMIN: admin - KEYCLOAK_ADMIN_PASSWORD: ${{KEYCLOAK_ADMIN_PASSWORD:-admin}} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin} KC_DB: postgres - KC_DB_URL_HOST: pg-{s} + KC_DB_URL_HOST: pg-{{s}} KC_DB_URL_DATABASE: keycloak KC_DB_USERNAME: clarity - KC_DB_PASSWORD: ${{POSTGRES_PASSWORD:-clarity-dev}} - KC_HOSTNAME: {kcHostname} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD:-clarity-dev} + KC_HOSTNAME: {{kcHostname}} KC_HOSTNAME_STRICT: "false" KC_HTTP_ENABLED: "true" expose: @@ -614,11 +614,11 @@ public class ClarityContainerService( networks: - clarity-net extra_hosts: - - "{kcHostname}:host-gateway" - {dependsBlock} + - "{{kcHostname}}:host-gateway" + {{dependsBlock}} labels: clarity.managed: "true" - clarity.subdomain: {s} + clarity.subdomain: {{s}} clarity.component: keycloak """); } @@ -626,14 +626,14 @@ public class ClarityContainerService( // ── Vault ───────────────────────────────────────────────────────────── if (stack.Vault == ComponentMode.OwnContainer) { - sb.AppendLine($""" - vault-{s}: + sb.AppendLine($$""" + vault-{{s}}: image: hashicorp/vault:latest restart: unless-stopped cap_add: - IPC_LOCK environment: - VAULT_DEV_ROOT_TOKEN_ID: ${{VAULT_TOKEN:-vault-dev-root}} + VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_TOKEN:-vault-dev-root} VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" expose: - "8200" @@ -643,7 +643,7 @@ public class ClarityContainerService( - clarity-net labels: clarity.managed: "true" - clarity.subdomain: {s} + clarity.subdomain: {{s}} clarity.component: vault """); } @@ -651,14 +651,14 @@ public class ClarityContainerService( // ── MinIO ───────────────────────────────────────────────────────────── if (stack.Minio == ComponentMode.OwnContainer) { - sb.AppendLine($""" - minio-{s}: + sb.AppendLine($$""" + minio-{{s}}: image: minio/minio:latest restart: unless-stopped command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: ${{MINIO_ROOT_USER:-minio}} - MINIO_ROOT_PASSWORD: ${{MINIO_ROOT_PASSWORD:-minio-dev}} + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio-dev} expose: - "9000" - "9001" @@ -669,7 +669,7 @@ public class ClarityContainerService( - clarity-net labels: clarity.managed: "true" - clarity.subdomain: {s} + clarity.subdomain: {{s}} clarity.component: minio """); } diff --git a/clarity.controlplane/src/api/infraApi.ts b/clarity.controlplane/src/api/infraApi.ts index 93450dc..bef0cb5 100644 --- a/clarity.controlplane/src/api/infraApi.ts +++ b/clarity.controlplane/src/api/infraApi.ts @@ -36,6 +36,14 @@ export function streamComposeUp(onLine: (line: string) => void, onDone: () => vo return src; } +/** Force-recreates all containers and removes orphans — fixes name-conflict errors. */ +export function streamComposeForceUp(onLine: (line: string) => void, onDone: () => void): EventSource { + const src = new EventSource(`${BASE_URL}/api/infra/compose/up-force/stream`); + src.onmessage = (e) => onLine(e.data); + src.onerror = () => { onDone(); src.close(); }; + return src; +} + export function streamComposeDown(onLine: (line: string) => void, onDone: () => void): EventSource { const src = new EventSource(`${BASE_URL}/api/infra/compose/down/stream`); src.onmessage = (e) => onLine(e.data); diff --git a/clarity.controlplane/src/pages/ImageBuildPage.tsx b/clarity.controlplane/src/pages/ImageBuildPage.tsx index afcda2a..3f10ef2 100644 --- a/clarity.controlplane/src/pages/ImageBuildPage.tsx +++ b/clarity.controlplane/src/pages/ImageBuildPage.tsx @@ -1,9 +1,14 @@ import { useEffect, useRef, useState } from 'react'; import { Button, Callout, Intent, Tag, Spinner, - HTMLTable, Card, Elevation, + HTMLTable, Card, Elevation, Tabs, Tab, type TabId, + FormGroup, InputGroup, } from '@blueprintjs/core'; import { getImageStatus, getBuildHistory, type ImageBuildStatus, type BuildRecord } from '../api/provisioningApi'; +import { + getInfraStatus, streamComposeUp, streamComposeForceUp, streamComposeDown, + type InfraService, +} from '../api/infraApi'; const BASE_URL = import.meta.env.VITE_API_URL ?? ''; @@ -13,9 +18,13 @@ const STATUS_INTENT: Record = { Running: Intent.PRIMARY, }; -// ── Live terminal ───────────────────────────────────────────────────────────── +// ── Shared terminal ─────────────────────────────────────────────────────────── -function BuildTerminal({ lines }: { lines: string[] }) { +function Terminal({ lines, height = 360, placeholder = 'Waiting for output…' }: { + lines: string[]; + height?: number; + placeholder?: string; +}) { const ref = useRef(null); useEffect(() => { @@ -26,28 +35,28 @@ function BuildTerminal({ lines }: { lines: string[] }) {
{lines.length === 0 ? ( - Waiting for build output… + {placeholder} ) : ( lines.map((l, i) => { - const isError = l.startsWith('✖'); + const isError = l.startsWith('✖') || l.toLowerCase().includes('error'); const isSuccess = l.startsWith('✔'); const isSep = l.startsWith('──'); - const color = isError ? '#f85149' : isSuccess ? '#3fb950' : isSep ? '#484f58' : undefined; + const color = isError ? '#f85149' : isSuccess ? '#3fb950' : isSep ? '#484f58' : undefined; return
{l}
; }) )} @@ -89,9 +98,266 @@ function BuildHistoryTable({ records }: { records: BuildRecord[] }) { ); } +// ── Platform tab ────────────────────────────────────────────────────────────── + +function PlatformTab() { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [composeBusy, setBusy] = useState<'up' | 'force' | 'down' | null>(null); + const [lines, setLines] = useState([]); + const sseRef = useRef(null); + + const refresh = () => { + setLoading(true); + getInfraStatus() + .then(d => setServices(d.services)) + .catch(() => {}) + .finally(() => setLoading(false)); + }; + + useEffect(() => { refresh(); }, []); + + function startStream( + streamer: (onLine: (l: string) => void, onDone: () => void) => EventSource, + label: 'up' | 'force' | 'down', + ) { + sseRef.current?.close(); + setLines([`▶ compose ${label}…`]); + setBusy(label); + const src = streamer( + (line) => setLines(prev => [...prev, line]), + () => { setBusy(null); refresh(); }, + ); + sseRef.current = src; + } + + const running = services.filter(s => s.status === 'running').length; + const statusIntent = services.length === 0 ? Intent.NONE + : running === services.length ? Intent.SUCCESS + : running === 0 ? Intent.DANGER + : Intent.WARNING; + + return ( +
+ + {loading ? : ( + + {services.length === 0 ? 'Not checked' : `${running} / ${services.length} running`} + + )} + +
+ + + +
+
+ + {services.length > 0 && ( +
+ {services.map(s => ( + + {s.container} + + ))} +
+ )} + + +
+ ); +} + +// ── Verify tab ──────────────────────────────────────────────────────────────── + +function VerifyTab() { + const [ehContainer, setEhContainer] = useState(''); + const [ehResult, setEhResult] = useState(null); + const [ehLoading, setEhLoading] = useState(false); + const [ehError, setEhError] = useState(null); + + const [dnsContainer, setDnsContainer] = useState(''); + const [dnsUrl, setDnsUrl] = useState('https://keycloak.clarity.test/health/ready'); + const [dnsResult, setDnsResult] = useState<{ success: boolean; output: string; error: string } | null>(null); + const [dnsLoading, setDnsLoading] = useState(false); + + const [subdomain, setSubdomain] = useState(''); + const [artifact, setArtifact] = useState(null); + const [artLoading, setArtLoading] = useState(false); + const [artError, setArtError] = useState(null); + + async function checkExtraHosts() { + setEhLoading(true); setEhResult(null); setEhError(null); + try { + const res = await fetch(`${BASE_URL}/api/image/verify/extra-hosts/${encodeURIComponent(ehContainer)}`); + const data = await res.json(); + if (!res.ok) { setEhError(data.error ?? 'Not found'); return; } + setEhResult(JSON.stringify(data.extraHosts, null, 2)); + } catch (e) { + setEhError(e instanceof Error ? e.message : 'Unknown error'); + } finally { setEhLoading(false); } + } + + async function runDnsTest() { + setDnsLoading(true); setDnsResult(null); + try { + const res = await fetch(`${BASE_URL}/api/image/verify/dns-test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ container: dnsContainer, url: dnsUrl }), + }); + const data = await res.json(); + setDnsResult({ success: data.success, output: data.output ?? '', error: data.error ?? '' }); + } catch (e) { + setDnsResult({ success: false, output: '', error: e instanceof Error ? e.message : 'Unknown error' }); + } finally { setDnsLoading(false); } + } + + async function viewArtifact() { + setArtLoading(true); setArtifact(null); setArtError(null); + try { + const res = await fetch(`${BASE_URL}/api/image/artifact/${encodeURIComponent(subdomain)}`); + const data = await res.json(); + if (!res.ok) { setArtError(data.error ?? 'Not found'); return; } + setArtifact(data.content); + } catch (e) { + setArtError(e instanceof Error ? e.message : 'Unknown error'); + } finally { setArtLoading(false); } + } + + return ( +
+ + +

1 · Extra Hosts Check

+

+ Verifies *.clarity.test → host-gateway was injected so OIDC discovery + traffic routes through nginx correctly. +

+ + setEhContainer(e.target.value)} + placeholder="fdev-app-clarity-01000001" + rightElement={ + + } + /> + + {ehError && {ehError}} + {ehResult && ( +
{ehResult}
+ )} +
+ + +

2 · DNS Resolution Test

+

+ Runs curl from inside the container to verify *.clarity.test resolves + through nginx — the critical path for Keycloak JWT validation. +

+
+ + setDnsContainer(e.target.value)} + placeholder="fdev-app-clarity-01000001" + /> + + + setDnsUrl(e.target.value)} /> + + +
+ {dnsResult && ( + + {dnsResult.success + ? '✔ Reachable — DNS and nginx routing is working correctly.' + : '✖ Unreachable — check nginx/dnsmasq or extra_hosts injection.'} + {(dnsResult.output || dnsResult.error) && ( +
+                {dnsResult.output || dnsResult.error}
+              
+ )} +
+ )} +
+ + +

3 · Compose Artifact

+

+ View the generated docker-compose.yml saved to{' '} + ClientAssets/{'{subdomain}'}/ after provisioning. +

+ + setSubdomain(e.target.value)} + placeholder="acme" + rightElement={ + + } + /> + + {artError && {artError}} + {artifact && ( +
{artifact}
+ )} +
+
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────────── export default function ImageBuildPage() { + const [tab, setTab] = useState('build'); const [status, setStatus] = useState(null); const [history, setHistory] = useState([]); const [building, setBuilding] = useState(false); @@ -162,17 +428,11 @@ export default function ImageBuildPage() {

Image Build

-

Build the clarity-server Docker image from the current repo.

+

+ Build and verify the clarity-server Docker image. + Use Platform to manage infra, Verify to inspect a provisioned tenant. +

-
{/* ── Status bar ── */} @@ -192,9 +452,7 @@ export default function ImageBuildPage() { {status.lastMessage} )} - {lastBuilt && ( - Last built: {lastBuilt} - )} + {lastBuilt && Last built: {lastBuilt}} ) : ( @@ -207,25 +465,55 @@ export default function ImageBuildPage() { )} - {/* ── Terminal ── */} -
-
-

Output

- {logs.length > 0 && !building && ( -
- -
+ {/* ── Tabs ── */} + - {/* ── History ── */} -
-
-

Build History

-
- -
+ +
+
+

Output

+
+ {logs.length > 0 && !building && ( +
+
+ +
+
+
+

History

+
+ +
+
+ } + /> + + } + /> + + } + /> + + ); }