feat: expose frontend through YAML public entry
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
This commit is contained in:
@@ -455,6 +455,20 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "frontend") {
|
||||
const { runFrontendPublicExposureCommand } = await import("./src/frontend-public-exposure");
|
||||
const result = await runFrontendPublicExposureCommand(readConfig(), args.slice(1));
|
||||
const ok = (result as { ok?: unknown }).ok !== false;
|
||||
if (isRenderedCliResult(result)) {
|
||||
emitText(result.renderedText, result.command || commandName);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
emitJson(commandName, result, ok);
|
||||
if (!ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args);
|
||||
if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) {
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
import { rootPath } from "./config";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers, renderSimpleReverseProxyCaddySiteBlock } from "./pk01-caddy";
|
||||
import {
|
||||
asRecord,
|
||||
booleanField,
|
||||
integerField,
|
||||
parseOpsApplyOptions,
|
||||
parseOpsCommonOptions,
|
||||
readYamlRecord,
|
||||
recordField,
|
||||
stringField,
|
||||
} from "./platform-infra-ops-library";
|
||||
|
||||
const configPath = rootPath("config", "frontend.yaml");
|
||||
const configLabel = "config/frontend.yaml";
|
||||
const serviceName = "unidesk-frontend";
|
||||
|
||||
interface FrontendExposureConfig {
|
||||
metadata: { name: string };
|
||||
publicExposure: {
|
||||
enabled: boolean;
|
||||
publicBaseUrl: string;
|
||||
dns: { hostname: string; expectedA: string; resolvers: string[] };
|
||||
upstream: { host: string; port: number };
|
||||
pk01: {
|
||||
route: string;
|
||||
caddyConfigPath: string;
|
||||
caddyServiceName: string;
|
||||
responseHeaderTimeoutSeconds: number;
|
||||
};
|
||||
probes: { healthPath: string; rootPath: string };
|
||||
};
|
||||
}
|
||||
|
||||
export async function runFrontendPublicExposureCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
||||
const [scope = "public-exposure", action = "plan", ...rest] = args;
|
||||
if (scope !== "public-exposure") return { ok: false, error: "unsupported-frontend-command", args, help: frontendPublicExposureHelp() };
|
||||
if (action === "plan") {
|
||||
const result = plan();
|
||||
return wantsFull(rest) ? result : renderPlan(result);
|
||||
}
|
||||
if (action === "apply") {
|
||||
const options = parseOpsApplyOptions(rest);
|
||||
const result = await apply(config, options.confirm);
|
||||
return options.full || options.raw ? result : renderApply(result);
|
||||
}
|
||||
if (action === "status") {
|
||||
const options = parseCommon(rest);
|
||||
const result = status();
|
||||
return options.full || options.raw ? result : renderStatus(result);
|
||||
}
|
||||
if (action === "validate") {
|
||||
const options = parseCommon(rest);
|
||||
const result = validate();
|
||||
return options.full || options.raw ? result : renderValidate(result);
|
||||
}
|
||||
return { ok: false, error: "unsupported-frontend-public-exposure-command", args, help: frontendPublicExposureHelp() };
|
||||
}
|
||||
|
||||
export function frontendPublicExposureHelp(): Record<string, unknown> {
|
||||
return {
|
||||
command: "frontend public-exposure plan|apply|status|validate",
|
||||
examples: [
|
||||
"bun scripts/cli.ts frontend public-exposure plan",
|
||||
"bun scripts/cli.ts frontend public-exposure apply --dry-run",
|
||||
"bun scripts/cli.ts frontend public-exposure apply --confirm",
|
||||
"bun scripts/cli.ts frontend public-exposure status",
|
||||
"bun scripts/cli.ts frontend public-exposure validate",
|
||||
],
|
||||
configPath: configLabel,
|
||||
description: "Expose the YAML-declared UniDesk frontend through PK01 Caddy HTTPS using a managed Caddy block.",
|
||||
};
|
||||
}
|
||||
|
||||
function plan(): Record<string, unknown> {
|
||||
const frontend = readFrontendExposureConfig();
|
||||
const exposure = frontend.publicExposure;
|
||||
return {
|
||||
ok: true,
|
||||
service: serviceName,
|
||||
configPath: configLabel,
|
||||
metadata: frontend.metadata,
|
||||
publicExposure: exposureSummary(frontend),
|
||||
caddy: {
|
||||
marker: caddyManagedBlockMarkers(serviceName).start,
|
||||
siteBlockFingerprint: fingerprint(renderCaddySiteBlock(frontend)),
|
||||
siteBlockPreview: renderCaddySiteBlock(frontend),
|
||||
},
|
||||
policy: policyChecks(frontend),
|
||||
next: {
|
||||
dryRun: "bun scripts/cli.ts frontend public-exposure apply --dry-run",
|
||||
apply: "bun scripts/cli.ts frontend public-exposure apply --confirm",
|
||||
status: "bun scripts/cli.ts frontend public-exposure status",
|
||||
validate: "bun scripts/cli.ts frontend public-exposure validate",
|
||||
},
|
||||
valuesPrinted: false,
|
||||
publicBaseUrl: exposure.publicBaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function apply(config: UniDeskConfig, confirm: boolean): Promise<Record<string, unknown>> {
|
||||
const frontend = readFrontendExposureConfig();
|
||||
const policy = policyChecks(frontend);
|
||||
const failedPolicy = policy.filter((item) => item.ok === false);
|
||||
if (!confirm) {
|
||||
return {
|
||||
ok: failedPolicy.length === 0,
|
||||
mode: "dry-run",
|
||||
publicExposure: exposureSummary(frontend),
|
||||
caddy: {
|
||||
marker: caddyManagedBlockMarkers(serviceName).start,
|
||||
siteBlockFingerprint: fingerprint(renderCaddySiteBlock(frontend)),
|
||||
siteBlockPreview: renderCaddySiteBlock(frontend),
|
||||
},
|
||||
policy,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
if (failedPolicy.length > 0) {
|
||||
return { ok: false, mode: "policy-blocked", publicExposure: exposureSummary(frontend), policy, valuesPrinted: false };
|
||||
}
|
||||
|
||||
const exposure = frontend.publicExposure;
|
||||
const upstreamProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath);
|
||||
const caddy = exposure.enabled
|
||||
? await applyPk01CaddySiteBlock(
|
||||
config,
|
||||
serviceName,
|
||||
{
|
||||
enabled: exposure.enabled,
|
||||
dns: { hostname: exposure.dns.hostname },
|
||||
frpc: { remotePort: exposure.upstream.port },
|
||||
pk01: exposure.pk01,
|
||||
},
|
||||
renderCaddySiteBlock(frontend),
|
||||
caddyManagedBlockMarkers(serviceName),
|
||||
)
|
||||
: { ok: true, action: "not-enabled" };
|
||||
const publicHealthProbe = exposure.enabled ? httpProbeWithRetry(() => httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath), 8, 1000) : { ok: true, skipped: true };
|
||||
const publicRootProbe = exposure.enabled ? httpProbeWithRetry(() => httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath), 4, 1000) : { ok: true, skipped: true };
|
||||
return {
|
||||
ok: upstreamProbe.ok === true && caddy.ok !== false && publicHealthProbe.ok === true && publicRootProbe.ok === true,
|
||||
mode: "confirmed",
|
||||
publicExposure: exposureSummary(frontend),
|
||||
upstreamProbe,
|
||||
pk01Caddy: caddy,
|
||||
publicHealthProbe,
|
||||
publicRootProbe,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function status(): Record<string, unknown> {
|
||||
const frontend = readFrontendExposureConfig();
|
||||
const exposure = frontend.publicExposure;
|
||||
const upstreamHealthProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath);
|
||||
const publicHealthProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath) : { ok: true, skipped: true };
|
||||
const publicRootProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath) : { ok: true, skipped: true };
|
||||
return {
|
||||
ok: upstreamHealthProbe.ok === true && publicHealthProbe.ok === true && publicRootProbe.ok === true,
|
||||
publicExposure: exposureSummary(frontend),
|
||||
upstreamHealthProbe,
|
||||
publicHealthProbe,
|
||||
publicRootProbe,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function validate(): Record<string, unknown> {
|
||||
const frontend = readFrontendExposureConfig();
|
||||
const exposure = frontend.publicExposure;
|
||||
const dns = dnsProbe(exposure.dns.hostname);
|
||||
const upstreamHealthProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath);
|
||||
const publicHealthProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath) : { ok: true, skipped: true };
|
||||
const publicRootProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath) : { ok: true, skipped: true };
|
||||
return {
|
||||
ok: dns.addresses.includes(exposure.dns.expectedA) && upstreamHealthProbe.ok === true && publicHealthProbe.ok === true && publicRootProbe.ok === true,
|
||||
publicExposure: exposureSummary(frontend),
|
||||
dns,
|
||||
upstreamHealthProbe,
|
||||
publicHealthProbe,
|
||||
publicRootProbe,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readFrontendExposureConfig(): FrontendExposureConfig {
|
||||
const yaml = readYamlRecord(configPath, "UniDeskFrontendConfig");
|
||||
const metadata = recordField(yaml, "metadata", "frontend");
|
||||
const exposure = recordField(yaml, "publicExposure", "frontend");
|
||||
const dns = recordField(exposure, "dns", "frontend.publicExposure");
|
||||
const upstream = recordField(exposure, "upstream", "frontend.publicExposure");
|
||||
const pk01 = recordField(exposure, "pk01", "frontend.publicExposure");
|
||||
const probes = recordField(exposure, "probes", "frontend.publicExposure");
|
||||
const config: FrontendExposureConfig = {
|
||||
metadata: { name: stringField(metadata, "name", "frontend.metadata") },
|
||||
publicExposure: {
|
||||
enabled: booleanField(exposure, "enabled", "frontend.publicExposure"),
|
||||
publicBaseUrl: stringField(exposure, "publicBaseUrl", "frontend.publicExposure"),
|
||||
dns: {
|
||||
hostname: stringField(dns, "hostname", "frontend.publicExposure.dns"),
|
||||
expectedA: stringField(dns, "expectedA", "frontend.publicExposure.dns"),
|
||||
resolvers: stringArray(dns.resolvers, "frontend.publicExposure.dns.resolvers"),
|
||||
},
|
||||
upstream: {
|
||||
host: stringField(upstream, "host", "frontend.publicExposure.upstream"),
|
||||
port: portField(upstream, "port", "frontend.publicExposure.upstream"),
|
||||
},
|
||||
pk01: {
|
||||
route: stringField(pk01, "route", "frontend.publicExposure.pk01"),
|
||||
caddyConfigPath: stringField(pk01, "caddyConfigPath", "frontend.publicExposure.pk01"),
|
||||
caddyServiceName: stringField(pk01, "caddyServiceName", "frontend.publicExposure.pk01"),
|
||||
responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", "frontend.publicExposure.pk01"),
|
||||
},
|
||||
probes: {
|
||||
healthPath: pathField(probes, "healthPath", "frontend.publicExposure.probes"),
|
||||
rootPath: pathField(probes, "rootPath", "frontend.publicExposure.probes"),
|
||||
},
|
||||
},
|
||||
};
|
||||
validateFrontendExposureConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateFrontendExposureConfig(config: FrontendExposureConfig): void {
|
||||
const exposure = config.publicExposure;
|
||||
const url = new URL(exposure.publicBaseUrl);
|
||||
if (url.protocol !== "https:") throw new Error(`${configLabel}.publicExposure.publicBaseUrl must use https://`);
|
||||
if (url.hostname !== exposure.dns.hostname) throw new Error(`${configLabel}.publicExposure.publicBaseUrl hostname must match publicExposure.dns.hostname`);
|
||||
if (!/^[0-9.]+$/u.test(exposure.dns.expectedA)) throw new Error(`${configLabel}.publicExposure.dns.expectedA must be an IPv4 address`);
|
||||
if (exposure.pk01.responseHeaderTimeoutSeconds < 1) throw new Error(`${configLabel}.publicExposure.pk01.responseHeaderTimeoutSeconds must be >= 1`);
|
||||
}
|
||||
|
||||
function policyChecks(config: FrontendExposureConfig): Array<Record<string, unknown> & { ok: boolean }> {
|
||||
const exposure = config.publicExposure;
|
||||
return [
|
||||
{ name: "enabled", ok: exposure.enabled, detail: "publicExposure.enabled must be true for public closeout" },
|
||||
{ name: "public-https", ok: exposure.publicBaseUrl.startsWith("https://"), detail: exposure.publicBaseUrl },
|
||||
{ name: "dns-matches-url", ok: new URL(exposure.publicBaseUrl).hostname === exposure.dns.hostname, detail: exposure.dns.hostname },
|
||||
{ name: "pk01-managed-caddy", ok: exposure.pk01.route.length > 0 && exposure.pk01.caddyConfigPath.startsWith("/"), detail: `${exposure.pk01.route}:${exposure.pk01.caddyConfigPath}` },
|
||||
{ name: "upstream-declared", ok: exposure.upstream.host.length > 0 && exposure.upstream.port > 0, detail: `${exposure.upstream.host}:${exposure.upstream.port}` },
|
||||
];
|
||||
}
|
||||
|
||||
function renderCaddySiteBlock(config: FrontendExposureConfig): string {
|
||||
const exposure = config.publicExposure;
|
||||
return renderSimpleReverseProxyCaddySiteBlock({
|
||||
hostname: exposure.dns.hostname,
|
||||
upstream: `${exposure.upstream.host}:${exposure.upstream.port}`,
|
||||
responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
function exposureSummary(config: FrontendExposureConfig): Record<string, unknown> {
|
||||
const exposure = config.publicExposure;
|
||||
return {
|
||||
enabled: exposure.enabled,
|
||||
publicBaseUrl: exposure.publicBaseUrl,
|
||||
hostname: exposure.dns.hostname,
|
||||
expectedA: exposure.dns.expectedA,
|
||||
resolvers: exposure.dns.resolvers,
|
||||
upstream: `${exposure.upstream.host}:${exposure.upstream.port}`,
|
||||
pk01Route: exposure.pk01.route,
|
||||
marker: caddyManagedBlockMarkers(serviceName).start,
|
||||
probes: exposure.probes,
|
||||
owner: configLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function upstreamBaseUrl(config: FrontendExposureConfig): string {
|
||||
return `http://${config.publicExposure.upstream.host}:${config.publicExposure.upstream.port}`;
|
||||
}
|
||||
|
||||
function httpProbeWithRetry(run: () => Record<string, unknown>, attempts: number, delayMs: number): Record<string, unknown> {
|
||||
let latest = run();
|
||||
for (let attempt = 1; attempt < attempts && latest.ok !== true; attempt += 1) {
|
||||
spawnSync("sleep", [(delayMs / 1000).toFixed(3)]);
|
||||
latest = run();
|
||||
}
|
||||
return { ...latest, attempts };
|
||||
}
|
||||
|
||||
function httpProbe(baseUrl: string, path: string): Record<string, unknown> {
|
||||
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
|
||||
const result = spawnSync("curl", ["-fsS", "-L", "--connect-timeout", "5", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8" });
|
||||
const status = Number(result.stdout.trim());
|
||||
return {
|
||||
ok: result.status === 0 && Number.isInteger(status) && status >= 200 && status < 500,
|
||||
url,
|
||||
status: Number.isInteger(status) ? status : null,
|
||||
exitCode: result.status,
|
||||
stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function dnsProbe(hostname: string): Record<string, unknown> & { addresses: string[] } {
|
||||
const result = spawnSync("getent", ["hosts", hostname], { encoding: "utf8" });
|
||||
const addresses = result.stdout
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim().split(/\s+/u)[0] ?? "")
|
||||
.filter((value) => /^\d+\.\d+\.\d+\.\d+$/u.test(value));
|
||||
return { ok: result.status === 0 && addresses.length > 0, hostname, addresses, exitCode: result.status };
|
||||
}
|
||||
|
||||
function parseCommon(args: string[]): ReturnType<typeof parseOpsCommonOptions> {
|
||||
return parseOpsCommonOptions(args);
|
||||
}
|
||||
|
||||
function wantsFull(args: string[]): boolean {
|
||||
return args.includes("--full") || args.includes("--raw");
|
||||
}
|
||||
|
||||
function portField(record: Record<string, unknown>, key: string, path: string): number {
|
||||
const value = integerField(record, key, path);
|
||||
if (value < 1 || value > 65535) throw new Error(`${path}.${key} must be a TCP port`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function pathField(record: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(record, key, path);
|
||||
if (!value.startsWith("/")) throw new Error(`${path}.${key} must start with /`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of strings`);
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function fingerprint(value: string): string {
|
||||
const result = spawnSync("sha256sum", { input: value, encoding: "utf8" });
|
||||
return `sha256:${(result.stdout.split(/\s+/u)[0] ?? "").trim()}`;
|
||||
}
|
||||
|
||||
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
|
||||
const exposure = rec(result.publicExposure);
|
||||
const failed = arr(result.policy).filter((item) => item.ok === false);
|
||||
const lines = [
|
||||
"FRONTEND PUBLIC-EXPOSURE PLAN",
|
||||
...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [
|
||||
["CONFIG", str(result.configPath), "owner", str(exposure.owner)],
|
||||
["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)],
|
||||
["DNS", str(exposure.hostname), "expectedA", str(exposure.expectedA)],
|
||||
["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)],
|
||||
["CADDY", str(exposure.marker), "block", str(rec(result.caddy).siteBlockFingerprint)],
|
||||
["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"],
|
||||
]),
|
||||
"",
|
||||
"NEXT",
|
||||
" dry-run: bun scripts/cli.ts frontend public-exposure apply --dry-run",
|
||||
" apply: bun scripts/cli.ts frontend public-exposure apply --confirm",
|
||||
" status: bun scripts/cli.ts frontend public-exposure status",
|
||||
];
|
||||
return rendered(result, "frontend public-exposure plan", lines);
|
||||
}
|
||||
|
||||
function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
||||
const exposure = rec(result.publicExposure);
|
||||
const upstreamProbe = rec(result.upstreamProbe);
|
||||
const publicHealthProbe = rec(result.publicHealthProbe);
|
||||
const publicRootProbe = rec(result.publicRootProbe);
|
||||
const caddy = rec(result.pk01Caddy);
|
||||
const lines = [
|
||||
"FRONTEND PUBLIC-EXPOSURE APPLY",
|
||||
...table(["PUBLIC", "MODE", "STATUS"], [[str(exposure.publicBaseUrl), str(result.mode), result.ok === false ? "failed" : "ok"]]),
|
||||
"",
|
||||
"CHECKS",
|
||||
...table(["CHECK", "OK", "DETAIL"], [
|
||||
["upstream-health", bool(upstreamProbe.ok), str(upstreamProbe.url, "-")],
|
||||
["pk01-caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))],
|
||||
["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")],
|
||||
["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")],
|
||||
]),
|
||||
"",
|
||||
"NEXT",
|
||||
" status: bun scripts/cli.ts frontend public-exposure status",
|
||||
" validate: bun scripts/cli.ts frontend public-exposure validate",
|
||||
];
|
||||
return rendered(result, "frontend public-exposure apply", lines);
|
||||
}
|
||||
|
||||
function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
||||
const exposure = rec(result.publicExposure);
|
||||
const upstreamHealthProbe = rec(result.upstreamHealthProbe);
|
||||
const publicHealthProbe = rec(result.publicHealthProbe);
|
||||
const publicRootProbe = rec(result.publicRootProbe);
|
||||
const lines = [
|
||||
"FRONTEND PUBLIC-EXPOSURE STATUS",
|
||||
...table(["PUBLIC", "UPSTREAM", "STATUS"], [[str(exposure.publicBaseUrl), str(exposure.upstream), result.ok === false ? "failed" : "ok"]]),
|
||||
"",
|
||||
"CHECKS",
|
||||
...table(["CHECK", "OK", "DETAIL"], [
|
||||
["upstream-health", bool(upstreamHealthProbe.ok), str(upstreamHealthProbe.url, "-")],
|
||||
["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")],
|
||||
["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")],
|
||||
]),
|
||||
];
|
||||
return rendered(result, "frontend public-exposure status", lines);
|
||||
}
|
||||
|
||||
function renderValidate(result: Record<string, unknown>): RenderedCliResult {
|
||||
const dns = rec(result.dns);
|
||||
const upstreamHealthProbe = rec(result.upstreamHealthProbe);
|
||||
const publicHealthProbe = rec(result.publicHealthProbe);
|
||||
const publicRootProbe = rec(result.publicRootProbe);
|
||||
const lines = [
|
||||
"FRONTEND PUBLIC-EXPOSURE VALIDATE",
|
||||
...table(["CHECK", "OK", "DETAIL"], [
|
||||
["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"],
|
||||
["upstream-health", bool(upstreamHealthProbe.ok), str(upstreamHealthProbe.url, "-")],
|
||||
["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")],
|
||||
["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")],
|
||||
]),
|
||||
];
|
||||
return rendered(result, "frontend public-exposure validate", lines);
|
||||
}
|
||||
|
||||
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
||||
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function rec(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function arr(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(rec) : [];
|
||||
}
|
||||
|
||||
function values(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function str(value: unknown, fallback = "-"): string {
|
||||
return value === undefined || value === null || value === "" ? fallback : String(value);
|
||||
}
|
||||
|
||||
function bool(value: unknown): string {
|
||||
return value === true ? "yes" : value === false ? "no" : "-";
|
||||
}
|
||||
|
||||
function table(headers: string[], rows: string[][]): string[] {
|
||||
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
||||
const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
||||
return [renderRow(headers), ...rows.map(renderRow)];
|
||||
}
|
||||
Reference in New Issue
Block a user