|
|
|
@@ -0,0 +1,769 @@
|
|
|
|
|
// SPEC: PJ2026-01060309 cross-node-proxy-ci-benchmark draft-2026-06-26-k3s-build-benchmark.
|
|
|
|
|
// Responsibility: Generic platform-infra k3s build benchmark coordinator with proxyserver traffic evidence.
|
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import { rootPath } from "./config";
|
|
|
|
|
import { runCommand, type CommandResult } from "./command";
|
|
|
|
|
import type { RenderedCliResult } from "./output";
|
|
|
|
|
import { egressProxyTrafficScript, type EgressProxyTrafficSpec } from "./egress-proxy-traffic";
|
|
|
|
|
import { readSub2ApiConfig } from "./platform-infra/config";
|
|
|
|
|
import type { Sub2ApiTargetConfig } from "./platform-infra/entry";
|
|
|
|
|
import { resolveTarget } from "./platform-infra/manifest";
|
|
|
|
|
|
|
|
|
|
const BENCHMARK_CONFIG_PATH = "config/platform-infra/egress-proxy-benchmarks.yaml";
|
|
|
|
|
const BENCHMARK_APP = "unidesk-k3s-build-benchmark";
|
|
|
|
|
|
|
|
|
|
type K3sBuildAction = "start" | "status" | "logs";
|
|
|
|
|
|
|
|
|
|
interface K3sBuildBenchmarkOptions {
|
|
|
|
|
action: K3sBuildAction;
|
|
|
|
|
targets: readonly string[];
|
|
|
|
|
profile: string;
|
|
|
|
|
confirm: boolean;
|
|
|
|
|
dryRun: boolean;
|
|
|
|
|
timeoutSeconds: number;
|
|
|
|
|
tailLines: number;
|
|
|
|
|
trafficSampleSeconds: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface K3sBuildBenchmarkProfile {
|
|
|
|
|
id: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
workload: "k3s-build";
|
|
|
|
|
description: string;
|
|
|
|
|
image: string;
|
|
|
|
|
imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
|
|
|
payloadMiB: number;
|
|
|
|
|
timeoutSeconds: number;
|
|
|
|
|
ttlSecondsAfterFinished: number;
|
|
|
|
|
noMirror: {
|
|
|
|
|
apt: string;
|
|
|
|
|
npmRegistry: string;
|
|
|
|
|
pipIndexUrl: string;
|
|
|
|
|
registryMirror: "forbidden";
|
|
|
|
|
};
|
|
|
|
|
aptPackages: readonly string[];
|
|
|
|
|
dependencyDownload: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
url: string;
|
|
|
|
|
chunks: number;
|
|
|
|
|
expectedMiB: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface K3sBuildBenchmarkConfig {
|
|
|
|
|
version: number;
|
|
|
|
|
kind: string;
|
|
|
|
|
metadata: { owner: string; relatedIssues: readonly number[] };
|
|
|
|
|
profiles: Record<string, K3sBuildBenchmarkProfile>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TargetPlan {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
targetId: string;
|
|
|
|
|
target?: Sub2ApiTargetConfig;
|
|
|
|
|
profile?: K3sBuildBenchmarkProfile;
|
|
|
|
|
blocker?: string;
|
|
|
|
|
detail?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TargetStatus {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
targetId: string;
|
|
|
|
|
profile: string;
|
|
|
|
|
state: string;
|
|
|
|
|
jobName: string;
|
|
|
|
|
runId: string;
|
|
|
|
|
startedAt: string;
|
|
|
|
|
completedAt: string;
|
|
|
|
|
durationSeconds: number | null;
|
|
|
|
|
outputMiB: number | null;
|
|
|
|
|
downloadMiB: number | null;
|
|
|
|
|
payloadMiB: number | null;
|
|
|
|
|
failureFamily: string;
|
|
|
|
|
logTail: string;
|
|
|
|
|
traffic?: TrafficSummary;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TrafficSummary {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
reason: string;
|
|
|
|
|
windowBytes: number;
|
|
|
|
|
rateBps: number;
|
|
|
|
|
processTotalBytes: number;
|
|
|
|
|
topClient: string;
|
|
|
|
|
topDestination: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function runK3sBuildBenchmarkCommand(args: string[]): RenderedCliResult {
|
|
|
|
|
const options = parseK3sBuildBenchmarkOptions(args);
|
|
|
|
|
const config = readK3sBuildBenchmarkConfig();
|
|
|
|
|
const plans = resolvePlans(config, options);
|
|
|
|
|
if (options.action === "start" && options.dryRun) return renderDryRun(plans, options);
|
|
|
|
|
if (options.action === "start") return startBenchmarks(plans, options);
|
|
|
|
|
return statusBenchmarks(plans, options);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseK3sBuildBenchmarkOptions(args: string[]): K3sBuildBenchmarkOptions {
|
|
|
|
|
const first = args[0];
|
|
|
|
|
const action: K3sBuildAction = first === "status" || first === "logs" ? first : "start";
|
|
|
|
|
const rest = action === "start" ? args : args.slice(1);
|
|
|
|
|
if (first === "--help" || first === "-h" || first === "help") {
|
|
|
|
|
throw new Error("platform-infra egress-proxy k3s-build-benchmark usage: k3s-build-benchmark [status|logs] --targets D601,D518 --profile no-mirror-600m [--dry-run|--confirm]");
|
|
|
|
|
}
|
|
|
|
|
const confirm = rest.includes("--confirm");
|
|
|
|
|
const explicitDryRun = rest.includes("--dry-run");
|
|
|
|
|
if (confirm && explicitDryRun) throw new Error("k3s-build-benchmark accepts only one of --confirm or --dry-run");
|
|
|
|
|
return {
|
|
|
|
|
action,
|
|
|
|
|
targets: parseTargetsOption(rest),
|
|
|
|
|
profile: option(rest, "--profile") ?? "no-mirror-600m",
|
|
|
|
|
confirm,
|
|
|
|
|
dryRun: action === "start" ? explicitDryRun || !confirm : false,
|
|
|
|
|
timeoutSeconds: positiveIntOption(rest, "--timeout-seconds", 60, 60),
|
|
|
|
|
tailLines: positiveIntOption(rest, "--tail-lines", action === "logs" ? 160 : 40, 400),
|
|
|
|
|
trafficSampleSeconds: positiveIntOption(rest, "--traffic-sample-seconds", 0, 45),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTargetsOption(args: string[]): readonly string[] {
|
|
|
|
|
const raw = option(args, "--targets") ?? option(args, "--target") ?? "D601";
|
|
|
|
|
if (raw === "all") {
|
|
|
|
|
const sub2api = readSub2ApiConfig();
|
|
|
|
|
return sub2api.targets
|
|
|
|
|
.filter((target) => target.enabled && target.egressProxy?.enabled === true)
|
|
|
|
|
.map((target) => target.id);
|
|
|
|
|
}
|
|
|
|
|
const targets = raw.split(",").map((item) => item.trim()).filter(Boolean);
|
|
|
|
|
if (targets.length === 0) throw new Error("--targets must contain at least one target id");
|
|
|
|
|
return [...new Set(targets)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolvePlans(config: K3sBuildBenchmarkConfig, options: K3sBuildBenchmarkOptions): readonly TargetPlan[] {
|
|
|
|
|
const sub2api = readSub2ApiConfig();
|
|
|
|
|
const profile = config.profiles[options.profile];
|
|
|
|
|
return options.targets.map((targetId): TargetPlan => {
|
|
|
|
|
if (profile === undefined) return { ok: false, targetId, blocker: "profile-missing", detail: `${BENCHMARK_CONFIG_PATH}.profiles.${options.profile} is missing` };
|
|
|
|
|
if (!profile.enabled) return { ok: false, targetId, profile, blocker: "profile-disabled", detail: `${BENCHMARK_CONFIG_PATH}.profiles.${profile.id}.enabled=false` };
|
|
|
|
|
if (profile.payloadMiB < 500) return { ok: false, targetId, profile, blocker: "payload-too-small", detail: `${BENCHMARK_CONFIG_PATH}.profiles.${profile.id}.payloadMiB must be >= 500` };
|
|
|
|
|
try {
|
|
|
|
|
const target = resolveTarget(sub2api, targetId);
|
|
|
|
|
if (target.egressProxy === null || !target.egressProxy.enabled) return { ok: false, targetId: target.id, target, profile, blocker: "egress-proxy-disabled", detail: `config/platform-infra/sub2api.yaml target ${target.id} has no enabled egressProxy` };
|
|
|
|
|
return { ok: true, targetId: target.id, target, profile };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return { ok: false, targetId, profile, blocker: "target-missing", detail: error instanceof Error ? error.message : String(error) };
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startBenchmarks(plans: readonly TargetPlan[], options: K3sBuildBenchmarkOptions): RenderedCliResult {
|
|
|
|
|
const rows = plans.map((plan) => {
|
|
|
|
|
if (!plan.ok || plan.target === undefined || plan.profile === undefined) {
|
|
|
|
|
return { ...plan, started: false, state: "blocked", jobName: "-", runId: "-", result: null };
|
|
|
|
|
}
|
|
|
|
|
const runId = `k3sbuild-${Date.now().toString(36)}-${plan.target.id.toLowerCase()}`;
|
|
|
|
|
const jobName = benchmarkJobName(plan.target, plan.profile, runId);
|
|
|
|
|
const manifest = benchmarkJobManifest(plan.target, plan.profile, runId, jobName);
|
|
|
|
|
const result = runTrans(plan.target.route, startScript(manifest, plan.target, plan.profile, runId, jobName), options.timeoutSeconds);
|
|
|
|
|
const parsed = parseJson(result.stdout);
|
|
|
|
|
return {
|
|
|
|
|
...plan,
|
|
|
|
|
started: result.exitCode === 0,
|
|
|
|
|
state: result.exitCode === 0 ? "started" : "failed",
|
|
|
|
|
jobName,
|
|
|
|
|
runId,
|
|
|
|
|
result: typeof parsed === "object" && parsed !== null ? parsed : { stdoutPreview: result.stdout.slice(0, 1200), stderrPreview: result.stderr.slice(-1200), exitCode: result.exitCode },
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const ok = rows.every((row) => row.ok && row.started);
|
|
|
|
|
const tableRows = rows.map((row) => [
|
|
|
|
|
row.targetId,
|
|
|
|
|
row.profile?.id ?? options.profile,
|
|
|
|
|
row.state,
|
|
|
|
|
row.jobName,
|
|
|
|
|
row.runId,
|
|
|
|
|
row.ok ? `status/logs/traffic` : `${row.blocker}: ${row.detail}`,
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
ok,
|
|
|
|
|
command: "platform-infra egress-proxy k3s-build-benchmark",
|
|
|
|
|
contentType: "text/plain",
|
|
|
|
|
renderedText: [
|
|
|
|
|
"PLATFORM-INFRA K3S BUILD BENCHMARK START",
|
|
|
|
|
"",
|
|
|
|
|
...table(["TARGET", "PROFILE", "STATE", "JOB", "RUN", "NEXT"], tableRows),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark status --targets ${rows.map((row) => row.targetId).join(",")} --profile ${options.profile}`,
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark status --targets ${rows.map((row) => row.targetId).join(",")} --profile ${options.profile} --traffic-sample-seconds 15`,
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark logs --targets ${rows.map((row) => row.targetId).join(",")} --profile ${options.profile}`,
|
|
|
|
|
"",
|
|
|
|
|
"Disclosure: start is fire-and-forget; the k3s Job uses emptyDir and a unique Job name, so benchmark work is not reused.",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusBenchmarks(plans: readonly TargetPlan[], options: K3sBuildBenchmarkOptions): RenderedCliResult {
|
|
|
|
|
const statuses = plans.map((plan): TargetStatus => {
|
|
|
|
|
if (!plan.ok || plan.target === undefined || plan.profile === undefined) {
|
|
|
|
|
return blockedStatus(plan, options.profile);
|
|
|
|
|
}
|
|
|
|
|
const result = runTrans(plan.target.route, statusScript(plan.target, plan.profile, options.tailLines), options.timeoutSeconds);
|
|
|
|
|
const parsed = parseJson(result.stdout);
|
|
|
|
|
const status = normalizeStatus(plan, parsed, result);
|
|
|
|
|
if (options.trafficSampleSeconds > 0) status.traffic = sampleTraffic(plan.target, options.trafficSampleSeconds, options.timeoutSeconds);
|
|
|
|
|
return status;
|
|
|
|
|
});
|
|
|
|
|
const ok = statuses.every((status) => status.ok || options.action === "logs");
|
|
|
|
|
const rows = statuses.map((status) => [
|
|
|
|
|
status.targetId,
|
|
|
|
|
status.profile,
|
|
|
|
|
status.state,
|
|
|
|
|
status.jobName,
|
|
|
|
|
status.durationSeconds === null ? "-" : `${status.durationSeconds}s`,
|
|
|
|
|
status.outputMiB === null ? "-" : `${status.outputMiB}MiB`,
|
|
|
|
|
status.downloadMiB === null ? "-" : `${status.downloadMiB}MiB`,
|
|
|
|
|
status.traffic === undefined ? "-" : bytes(status.traffic.windowBytes),
|
|
|
|
|
status.traffic === undefined ? "-" : rate(status.traffic.rateBps),
|
|
|
|
|
status.traffic === undefined ? "-" : bytes(status.traffic.processTotalBytes),
|
|
|
|
|
status.traffic === undefined ? "-" : status.traffic.topClient,
|
|
|
|
|
status.traffic === undefined ? "-" : status.traffic.topDestination,
|
|
|
|
|
status.failureFamily,
|
|
|
|
|
]);
|
|
|
|
|
const logSections = options.action === "logs"
|
|
|
|
|
? statuses.flatMap((status) => ["", `LOG ${status.targetId} ${status.jobName}`, status.logTail || "-"])
|
|
|
|
|
: [];
|
|
|
|
|
return {
|
|
|
|
|
ok,
|
|
|
|
|
command: `platform-infra egress-proxy k3s-build-benchmark ${options.action}`,
|
|
|
|
|
contentType: "text/plain",
|
|
|
|
|
renderedText: [
|
|
|
|
|
"PLATFORM-INFRA K3S BUILD BENCHMARK STATUS",
|
|
|
|
|
"",
|
|
|
|
|
...table(["TARGET", "PROFILE", "STATE", "JOB", "DURATION", "OUTPUT", "DOWNLOAD", "TRAFFIC_WINDOW", "TRAFFIC_RATE", "PROXY_CUM", "TOP_CLIENT", "TOP_DEST", "FAILURE"], rows),
|
|
|
|
|
...logSections,
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark status --targets ${statuses.map((status) => status.targetId).join(",")} --profile ${options.profile} --traffic-sample-seconds 15`,
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark logs --targets ${statuses.map((status) => status.targetId).join(",")} --profile ${options.profile}`,
|
|
|
|
|
"",
|
|
|
|
|
"Disclosure: traffic columns are proxyserver-side samples only when --traffic-sample-seconds is set; Secret/proxy values are redacted.",
|
|
|
|
|
].join("\n"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderDryRun(plans: readonly TargetPlan[], options: K3sBuildBenchmarkOptions): RenderedCliResult {
|
|
|
|
|
const rows = plans.map((plan) => [
|
|
|
|
|
plan.targetId,
|
|
|
|
|
plan.profile?.id ?? options.profile,
|
|
|
|
|
plan.ok ? "ok" : `blocked:${plan.blocker}`,
|
|
|
|
|
plan.target?.route ?? "-",
|
|
|
|
|
plan.target?.namespace ?? "-",
|
|
|
|
|
plan.target?.egressProxy?.serviceName ?? "-",
|
|
|
|
|
plan.profile === undefined ? "-" : `${plan.profile.payloadMiB}MiB`,
|
|
|
|
|
plan.profile === undefined ? "-" : `${plan.profile.dependencyDownload.expectedMiB}MiB`,
|
|
|
|
|
plan.detail ?? "no-mirror emptyDir unique-job",
|
|
|
|
|
]);
|
|
|
|
|
return {
|
|
|
|
|
ok: plans.every((plan) => plan.ok),
|
|
|
|
|
command: "platform-infra egress-proxy k3s-build-benchmark",
|
|
|
|
|
contentType: "text/plain",
|
|
|
|
|
renderedText: [
|
|
|
|
|
"PLATFORM-INFRA K3S BUILD BENCHMARK DRY-RUN",
|
|
|
|
|
"",
|
|
|
|
|
...table(["TARGET", "PROFILE", "STATUS", "ROUTE", "NAMESPACE", "PROXY", "PAYLOAD", "DOWNLOAD", "DETAIL"], rows),
|
|
|
|
|
"",
|
|
|
|
|
"NEXT",
|
|
|
|
|
` bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark --targets ${plans.map((plan) => plan.targetId).join(",")} --profile ${options.profile} --confirm`,
|
|
|
|
|
"",
|
|
|
|
|
`Config: ${BENCHMARK_CONFIG_PATH}`,
|
|
|
|
|
].join("\n"),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function benchmarkJobManifest(target: Sub2ApiTargetConfig, profile: K3sBuildBenchmarkProfile, runId: string, jobName: string): Record<string, unknown> {
|
|
|
|
|
const proxy = target.egressProxy;
|
|
|
|
|
if (proxy === null) throw new Error(`target ${target.id} has no egressProxy`);
|
|
|
|
|
const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`;
|
|
|
|
|
const noProxy = proxy.noProxy.join(",");
|
|
|
|
|
return {
|
|
|
|
|
apiVersion: "batch/v1",
|
|
|
|
|
kind: "Job",
|
|
|
|
|
metadata: {
|
|
|
|
|
name: jobName,
|
|
|
|
|
namespace: target.namespace,
|
|
|
|
|
labels: benchmarkLabels(target, profile, runId),
|
|
|
|
|
annotations: {
|
|
|
|
|
"unidesk.ai/no-mirror": JSON.stringify(profile.noMirror),
|
|
|
|
|
"unidesk.ai/payload-mib": String(profile.payloadMiB),
|
|
|
|
|
"unidesk.ai/dependency-download-mib": String(profile.dependencyDownload.expectedMiB),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
spec: {
|
|
|
|
|
backoffLimit: 0,
|
|
|
|
|
activeDeadlineSeconds: profile.timeoutSeconds,
|
|
|
|
|
ttlSecondsAfterFinished: profile.ttlSecondsAfterFinished,
|
|
|
|
|
template: {
|
|
|
|
|
metadata: { labels: benchmarkLabels(target, profile, runId) },
|
|
|
|
|
spec: {
|
|
|
|
|
restartPolicy: "Never",
|
|
|
|
|
containers: [{
|
|
|
|
|
name: "build",
|
|
|
|
|
image: profile.image,
|
|
|
|
|
imagePullPolicy: profile.imagePullPolicy,
|
|
|
|
|
command: ["/bin/sh", "-lc"],
|
|
|
|
|
args: [workloadScript(profile)],
|
|
|
|
|
env: [
|
|
|
|
|
{ name: "HTTP_PROXY", value: proxyUrl },
|
|
|
|
|
{ name: "HTTPS_PROXY", value: proxyUrl },
|
|
|
|
|
{ name: "ALL_PROXY", value: proxyUrl },
|
|
|
|
|
{ name: "http_proxy", value: proxyUrl },
|
|
|
|
|
{ name: "https_proxy", value: proxyUrl },
|
|
|
|
|
{ name: "all_proxy", value: proxyUrl },
|
|
|
|
|
{ name: "NO_PROXY", value: noProxy },
|
|
|
|
|
{ name: "no_proxy", value: noProxy },
|
|
|
|
|
{ name: "DEBIAN_FRONTEND", value: "noninteractive" },
|
|
|
|
|
{ name: "NPM_CONFIG_REGISTRY", value: profile.noMirror.npmRegistry },
|
|
|
|
|
{ name: "PIP_INDEX_URL", value: profile.noMirror.pipIndexUrl },
|
|
|
|
|
{ name: "BENCHMARK_TARGET", value: target.id },
|
|
|
|
|
{ name: "BENCHMARK_PROFILE", value: profile.id },
|
|
|
|
|
{ name: "BENCHMARK_RUN_ID", value: runId },
|
|
|
|
|
{ name: "PAYLOAD_MIB", value: String(profile.payloadMiB) },
|
|
|
|
|
{ name: "DOWNLOAD_URL", value: profile.dependencyDownload.url },
|
|
|
|
|
{ name: "DOWNLOAD_CHUNKS", value: String(profile.dependencyDownload.chunks) },
|
|
|
|
|
{ name: "DOWNLOAD_EXPECTED_MIB", value: String(profile.dependencyDownload.expectedMiB) },
|
|
|
|
|
{ name: "APT_PACKAGES", value: profile.aptPackages.join(" ") },
|
|
|
|
|
],
|
|
|
|
|
volumeMounts: [{ name: "work", mountPath: "/work" }],
|
|
|
|
|
}],
|
|
|
|
|
volumes: [{ name: "work", emptyDir: { sizeLimit: "3Gi" } }],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function workloadScript(profile: K3sBuildBenchmarkProfile): string {
|
|
|
|
|
return `set -eu
|
|
|
|
|
started_epoch="$(date +%s)"
|
|
|
|
|
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
|
|
|
work=/work/k3s-build-benchmark
|
|
|
|
|
download_dir="$work/download"
|
|
|
|
|
build_dir="$work/build"
|
|
|
|
|
output_dir="$work/output"
|
|
|
|
|
mkdir -p "$download_dir" "$build_dir" "$output_dir"
|
|
|
|
|
printf 'UNIDESK_K3S_BUILD_BENCHMARK_EVENT target=%s profile=%s run=%s payloadMiB=%s expectedDownloadMiB=%s noMirror=true\\n' "$BENCHMARK_TARGET" "$BENCHMARK_PROFILE" "$BENCHMARK_RUN_ID" "$PAYLOAD_MIB" "$DOWNLOAD_EXPECTED_MIB"
|
|
|
|
|
if grep -R -E 'npmmirror|daocloud|aliyun|tuna|ustc' /etc/apt/sources.list /etc/apt/sources.list.d >/tmp/mirror-check.out 2>/dev/null; then
|
|
|
|
|
cat /tmp/mirror-check.out >&2
|
|
|
|
|
echo "unexpected apt mirror in base image" >&2
|
|
|
|
|
exit 42
|
|
|
|
|
fi
|
|
|
|
|
apt-get -o Acquire::http::No-Cache=true -o Acquire::https::No-Cache=true update
|
|
|
|
|
apt-get -o Acquire::http::No-Cache=true -o Acquire::https::No-Cache=true install -y --no-install-recommends $APT_PACKAGES
|
|
|
|
|
cat > "$build_dir/bench.c" <<'C'
|
|
|
|
|
#include <stdint.h>
|
|
|
|
|
#include <stdio.h>
|
|
|
|
|
int main(void) {
|
|
|
|
|
uint64_t x = 1469598103934665603ULL;
|
|
|
|
|
for (uint64_t i = 0; i < 12000000ULL; ++i) {
|
|
|
|
|
x ^= i;
|
|
|
|
|
x *= 1099511628211ULL;
|
|
|
|
|
}
|
|
|
|
|
printf("%llu\\n", (unsigned long long)x);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
C
|
|
|
|
|
cc -O2 "$build_dir/bench.c" -o "$build_dir/bench"
|
|
|
|
|
"$build_dir/bench" > "$output_dir/compile-result.txt"
|
|
|
|
|
if [ "${profile.dependencyDownload.enabled ? "1" : "0"}" = "1" ]; then
|
|
|
|
|
i=1
|
|
|
|
|
while [ "$i" -le "$DOWNLOAD_CHUNKS" ]; do
|
|
|
|
|
curl -fL --retry 2 --connect-timeout 15 --max-time 240 "$DOWNLOAD_URL" -o "$download_dir/chunk-$i.bin"
|
|
|
|
|
i=$((i + 1))
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
download_mib="$(du -sm "$download_dir" | awk '{print $1}')"
|
|
|
|
|
rm -rf "$download_dir"
|
|
|
|
|
dd if=/dev/zero of="$output_dir/payload.bin" bs=1M count="$PAYLOAD_MIB" status=none
|
|
|
|
|
sha256sum "$output_dir/payload.bin" > "$output_dir/payload.sha256"
|
|
|
|
|
output_mib="$(du -sm "$output_dir" | awk '{print $1}')"
|
|
|
|
|
if [ "$output_mib" -lt 500 ]; then
|
|
|
|
|
echo "payload-too-small outputMiB=$output_mib" >&2
|
|
|
|
|
exit 43
|
|
|
|
|
fi
|
|
|
|
|
completed_epoch="$(date +%s)"
|
|
|
|
|
completed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
|
|
|
duration_seconds=$((completed_epoch - started_epoch))
|
|
|
|
|
printf 'UNIDESK_K3S_BUILD_BENCHMARK_RESULT {"ok":true,"target":"%s","profile":"%s","runId":"%s","startedAt":"%s","completedAt":"%s","durationSeconds":%s,"payloadMiB":%s,"downloadMiB":%s,"downloadExpectedMiB":%s,"outputMiB":%s,"noMirror":true,"aptMirror":"system-default","npmRegistry":"%s","pipIndexUrl":"%s"}\\n' "$BENCHMARK_TARGET" "$BENCHMARK_PROFILE" "$BENCHMARK_RUN_ID" "$started_at" "$completed_at" "$duration_seconds" "$PAYLOAD_MIB" "$download_mib" "$DOWNLOAD_EXPECTED_MIB" "$output_mib" "$NPM_CONFIG_REGISTRY" "$PIP_INDEX_URL"
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function benchmarkLabels(target: Sub2ApiTargetConfig, profile: K3sBuildBenchmarkProfile, runId: string): Record<string, string> {
|
|
|
|
|
return {
|
|
|
|
|
"app.kubernetes.io/name": BENCHMARK_APP,
|
|
|
|
|
"app.kubernetes.io/part-of": "platform-infra",
|
|
|
|
|
"app.kubernetes.io/managed-by": "unidesk",
|
|
|
|
|
"unidesk.ai/benchmark": "k3s-build",
|
|
|
|
|
"unidesk.ai/benchmark-profile": profile.id,
|
|
|
|
|
"unidesk.ai/runtime-node": target.id.toLowerCase(),
|
|
|
|
|
"unidesk.ai/run-id": runId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startScript(manifest: Record<string, unknown>, target: Sub2ApiTargetConfig, profile: K3sBuildBenchmarkProfile, runId: string, jobName: string): string {
|
|
|
|
|
const yaml = `${Bun.YAML.stringify(manifest).trim()}\n`;
|
|
|
|
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
|
|
|
|
return `
|
|
|
|
|
set -eu
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
|
manifest="$tmp/k3s-build-benchmark.yaml"
|
|
|
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
|
|
|
kubectl apply -f "$manifest" >/dev/null
|
|
|
|
|
printf '{"ok":true,"jobName":"%s","namespace":"%s","target":"%s","runId":"%s","profile":"%s"}\\n' ${shQuote(jobName)} ${shQuote(target.namespace)} ${shQuote(target.id)} ${shQuote(runId)} ${shQuote(profile.id)}
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusScript(target: Sub2ApiTargetConfig, profile: K3sBuildBenchmarkProfile, tailLines: number): string {
|
|
|
|
|
const selector = `app.kubernetes.io/name=${BENCHMARK_APP},unidesk.ai/benchmark-profile=${profile.id},unidesk.ai/runtime-node=${target.id.toLowerCase()}`;
|
|
|
|
|
return `
|
|
|
|
|
set -eu
|
|
|
|
|
python3 - ${shQuote(target.namespace)} ${shQuote(selector)} ${shQuote(String(tailLines))} <<'PY'
|
|
|
|
|
import json, re, subprocess, sys
|
|
|
|
|
namespace, selector, tail_lines_raw = sys.argv[1:4]
|
|
|
|
|
tail_lines = int(tail_lines_raw)
|
|
|
|
|
|
|
|
|
|
def kubectl(args):
|
|
|
|
|
return subprocess.run(["kubectl", "-n", namespace, *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
|
|
|
|
|
|
|
|
jobs_result = kubectl(["get", "jobs", "-l", selector, "-o", "json"])
|
|
|
|
|
if jobs_result.returncode != 0:
|
|
|
|
|
print(json.dumps({"ok": False, "reason": "kubectl-jobs-failed", "stderr": jobs_result.stderr[-2000:]}, ensure_ascii=False))
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
jobs = json.loads(jobs_result.stdout or "{}").get("items", [])
|
|
|
|
|
if not jobs:
|
|
|
|
|
print(json.dumps({"ok": False, "reason": "job-missing", "state": "missing"}, ensure_ascii=False))
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
jobs.sort(key=lambda item: item.get("metadata", {}).get("creationTimestamp", ""))
|
|
|
|
|
job = jobs[-1]
|
|
|
|
|
meta = job.get("metadata", {})
|
|
|
|
|
status = job.get("status", {})
|
|
|
|
|
job_name = meta.get("name") or "-"
|
|
|
|
|
labels = meta.get("labels", {})
|
|
|
|
|
pods_result = kubectl(["get", "pods", "-l", "job-name=" + job_name, "-o", "json"])
|
|
|
|
|
pods = json.loads(pods_result.stdout or "{}").get("items", []) if pods_result.returncode == 0 else []
|
|
|
|
|
pods.sort(key=lambda item: item.get("metadata", {}).get("creationTimestamp", ""))
|
|
|
|
|
pod_name = pods[-1].get("metadata", {}).get("name") if pods else None
|
|
|
|
|
logs = ""
|
|
|
|
|
if pod_name:
|
|
|
|
|
logs_result = kubectl(["logs", pod_name, "--tail", str(tail_lines)])
|
|
|
|
|
logs = logs_result.stdout if logs_result.returncode == 0 else logs_result.stderr
|
|
|
|
|
full_logs = ""
|
|
|
|
|
if pod_name:
|
|
|
|
|
full_result = kubectl(["logs", pod_name, "--tail", "800"])
|
|
|
|
|
full_logs = full_result.stdout if full_result.returncode == 0 else ""
|
|
|
|
|
match = None
|
|
|
|
|
for line in reversed(full_logs.splitlines()):
|
|
|
|
|
if line.startswith("UNIDESK_K3S_BUILD_BENCHMARK_RESULT "):
|
|
|
|
|
try:
|
|
|
|
|
match = json.loads(line.split(" ", 1)[1])
|
|
|
|
|
except Exception:
|
|
|
|
|
match = None
|
|
|
|
|
break
|
|
|
|
|
conditions = status.get("conditions") or []
|
|
|
|
|
failed = any(item.get("type") == "Failed" and item.get("status") == "True" for item in conditions)
|
|
|
|
|
succeeded = status.get("succeeded", 0) > 0
|
|
|
|
|
active = status.get("active", 0) > 0
|
|
|
|
|
if succeeded:
|
|
|
|
|
state = "succeeded"
|
|
|
|
|
elif failed:
|
|
|
|
|
state = "failed"
|
|
|
|
|
elif active:
|
|
|
|
|
state = "running"
|
|
|
|
|
else:
|
|
|
|
|
state = "pending"
|
|
|
|
|
failure_family = "none" if state == "succeeded" else "unknown"
|
|
|
|
|
tail_text = (full_logs or logs)[-4000:]
|
|
|
|
|
if state == "missing":
|
|
|
|
|
failure_family = "job-missing"
|
|
|
|
|
elif "ImagePullBackOff" in tail_text or "ErrImagePull" in tail_text:
|
|
|
|
|
failure_family = "image-pull"
|
|
|
|
|
elif "apt-get" in tail_text and ("Failed" in tail_text or "Unable to" in tail_text):
|
|
|
|
|
failure_family = "apt-download"
|
|
|
|
|
elif "curl:" in tail_text:
|
|
|
|
|
failure_family = "dependency-download"
|
|
|
|
|
elif "payload-too-small" in tail_text:
|
|
|
|
|
failure_family = "payload-too-small"
|
|
|
|
|
elif failed:
|
|
|
|
|
failure_family = "failed"
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": state == "succeeded",
|
|
|
|
|
"state": state,
|
|
|
|
|
"jobName": job_name,
|
|
|
|
|
"runId": labels.get("unidesk.ai/run-id") or "-",
|
|
|
|
|
"profile": labels.get("unidesk.ai/benchmark-profile") or "-",
|
|
|
|
|
"startedAt": status.get("startTime") or (match or {}).get("startedAt"),
|
|
|
|
|
"completedAt": status.get("completionTime") or (match or {}).get("completedAt"),
|
|
|
|
|
"result": match,
|
|
|
|
|
"failureFamily": failure_family,
|
|
|
|
|
"logTail": logs[-4000:],
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sampleTraffic(target: Sub2ApiTargetConfig, sampleSeconds: number, timeoutSeconds: number): TrafficSummary {
|
|
|
|
|
const spec = trafficSpec(target);
|
|
|
|
|
const result = runTrans(spec.route, egressProxyTrafficScript(spec, sampleSeconds), timeoutSeconds);
|
|
|
|
|
const parsed = parseJson(result.stdout);
|
|
|
|
|
const data = typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
|
|
|
const totals = record(data.totals);
|
|
|
|
|
const clients = arrayRecords(data.clients);
|
|
|
|
|
const topClient = clients[0] ?? {};
|
|
|
|
|
const destinations = arrayRecords(topClient.topDestinations);
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && data.ok !== false,
|
|
|
|
|
reason: text(data.reason, result.exitCode === 0 ? "ok" : "traffic-failed"),
|
|
|
|
|
windowBytes: number(totals.clientWindowTotalBytes),
|
|
|
|
|
rateBps: number(totals.clientTotalBps),
|
|
|
|
|
processTotalBytes: number(totals.processTotalBytes),
|
|
|
|
|
topClient: text(topClient.client),
|
|
|
|
|
topDestination: text(destinations[0]?.destination),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trafficSpec(target: Sub2ApiTargetConfig): EgressProxyTrafficSpec {
|
|
|
|
|
const proxy = target.egressProxy;
|
|
|
|
|
if (proxy === null) throw new Error(`target ${target.id} has no egressProxy`);
|
|
|
|
|
return {
|
|
|
|
|
scope: "platform-infra",
|
|
|
|
|
targetId: target.id,
|
|
|
|
|
route: target.route,
|
|
|
|
|
namespace: target.namespace,
|
|
|
|
|
deploymentName: proxy.deploymentName,
|
|
|
|
|
serviceName: proxy.serviceName,
|
|
|
|
|
port: proxy.listenPort,
|
|
|
|
|
sourceType: proxy.sourceType,
|
|
|
|
|
sourceRef: proxy.sourceRef,
|
|
|
|
|
sourceConfigRef: proxy.sourceConfigRef,
|
|
|
|
|
sourceFingerprint: proxy.sourceFingerprint,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeStatus(plan: TargetPlan, parsed: unknown, result: CommandResult): TargetStatus {
|
|
|
|
|
const data = typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : {};
|
|
|
|
|
const jobResult = record(data.result);
|
|
|
|
|
const status: TargetStatus = {
|
|
|
|
|
ok: result.exitCode === 0 && data.ok === true,
|
|
|
|
|
targetId: plan.targetId,
|
|
|
|
|
profile: text(data.profile, plan.profile?.id ?? "-"),
|
|
|
|
|
state: text(data.state, result.exitCode === 0 ? "unknown" : "failed"),
|
|
|
|
|
jobName: text(data.jobName),
|
|
|
|
|
runId: text(data.runId),
|
|
|
|
|
startedAt: text(data.startedAt),
|
|
|
|
|
completedAt: text(data.completedAt),
|
|
|
|
|
durationSeconds: nullableNumber(jobResult.durationSeconds),
|
|
|
|
|
outputMiB: nullableNumber(jobResult.outputMiB),
|
|
|
|
|
downloadMiB: nullableNumber(jobResult.downloadMiB),
|
|
|
|
|
payloadMiB: nullableNumber(jobResult.payloadMiB),
|
|
|
|
|
failureFamily: text(data.failureFamily, data.ok === true ? "none" : text(data.reason, "unknown")),
|
|
|
|
|
logTail: text(data.logTail, result.stderr.slice(-2000)),
|
|
|
|
|
};
|
|
|
|
|
return status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function blockedStatus(plan: TargetPlan, profile: string): TargetStatus {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
targetId: plan.targetId,
|
|
|
|
|
profile,
|
|
|
|
|
state: "blocked",
|
|
|
|
|
jobName: "-",
|
|
|
|
|
runId: "-",
|
|
|
|
|
startedAt: "-",
|
|
|
|
|
completedAt: "-",
|
|
|
|
|
durationSeconds: null,
|
|
|
|
|
outputMiB: null,
|
|
|
|
|
downloadMiB: null,
|
|
|
|
|
payloadMiB: plan.profile?.payloadMiB ?? null,
|
|
|
|
|
failureFamily: plan.blocker ?? "blocked",
|
|
|
|
|
logTail: plan.detail ?? "",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function benchmarkJobName(target: Sub2ApiTargetConfig, profile: K3sBuildBenchmarkProfile, runId: string): string {
|
|
|
|
|
const base = `k3s-build-${target.id.toLowerCase()}-${profile.id}-${runId.replace(/^k3sbuild-/u, "")}`.toLowerCase().replace(/[^a-z0-9-]/gu, "-");
|
|
|
|
|
return base.slice(0, 63).replace(/-+$/u, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readK3sBuildBenchmarkConfig(): K3sBuildBenchmarkConfig {
|
|
|
|
|
const raw = asRecord(Bun.YAML.parse(readFileSync(rootPath(BENCHMARK_CONFIG_PATH), "utf8")) as unknown, BENCHMARK_CONFIG_PATH);
|
|
|
|
|
const version = integerField(raw, "version", BENCHMARK_CONFIG_PATH);
|
|
|
|
|
const kind = stringField(raw, "kind", BENCHMARK_CONFIG_PATH);
|
|
|
|
|
if (kind !== "platform-infra-egress-proxy-benchmarks") throw new Error(`${BENCHMARK_CONFIG_PATH}.kind must be platform-infra-egress-proxy-benchmarks`);
|
|
|
|
|
const metadataRaw = asRecord(raw.metadata, "metadata");
|
|
|
|
|
const profilesRaw = asRecord(raw.profiles, "profiles");
|
|
|
|
|
const profiles = Object.fromEntries(Object.entries(profilesRaw).map(([id, value]) => [id, profileSpec(id, asRecord(value, `profiles.${id}`))]));
|
|
|
|
|
return {
|
|
|
|
|
version,
|
|
|
|
|
kind,
|
|
|
|
|
metadata: {
|
|
|
|
|
owner: stringField(metadataRaw, "owner", "metadata"),
|
|
|
|
|
relatedIssues: integerArrayField(metadataRaw, "relatedIssues", "metadata"),
|
|
|
|
|
},
|
|
|
|
|
profiles,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function profileSpec(id: string, raw: Record<string, unknown>): K3sBuildBenchmarkProfile {
|
|
|
|
|
const workload = stringField(raw, "workload", `profiles.${id}`);
|
|
|
|
|
if (workload !== "k3s-build") throw new Error(`profiles.${id}.workload must be k3s-build`);
|
|
|
|
|
const imagePullPolicy = stringField(raw, "imagePullPolicy", `profiles.${id}`);
|
|
|
|
|
if (imagePullPolicy !== "Always" && imagePullPolicy !== "IfNotPresent" && imagePullPolicy !== "Never") throw new Error(`profiles.${id}.imagePullPolicy must be Always, IfNotPresent, or Never`);
|
|
|
|
|
const noMirror = asRecord(raw.noMirror, `profiles.${id}.noMirror`);
|
|
|
|
|
const registryMirror = stringField(noMirror, "registryMirror", `profiles.${id}.noMirror`);
|
|
|
|
|
if (registryMirror !== "forbidden") throw new Error(`profiles.${id}.noMirror.registryMirror must be forbidden`);
|
|
|
|
|
const dependencyDownload = asRecord(raw.dependencyDownload, `profiles.${id}.dependencyDownload`);
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
enabled: booleanField(raw, "enabled", `profiles.${id}`),
|
|
|
|
|
workload,
|
|
|
|
|
description: stringField(raw, "description", `profiles.${id}`),
|
|
|
|
|
image: stringField(raw, "image", `profiles.${id}`),
|
|
|
|
|
imagePullPolicy,
|
|
|
|
|
payloadMiB: integerField(raw, "payloadMiB", `profiles.${id}`),
|
|
|
|
|
timeoutSeconds: integerField(raw, "timeoutSeconds", `profiles.${id}`),
|
|
|
|
|
ttlSecondsAfterFinished: integerField(raw, "ttlSecondsAfterFinished", `profiles.${id}`),
|
|
|
|
|
noMirror: {
|
|
|
|
|
apt: stringField(noMirror, "apt", `profiles.${id}.noMirror`),
|
|
|
|
|
npmRegistry: stringField(noMirror, "npmRegistry", `profiles.${id}.noMirror`),
|
|
|
|
|
pipIndexUrl: stringField(noMirror, "pipIndexUrl", `profiles.${id}.noMirror`),
|
|
|
|
|
registryMirror,
|
|
|
|
|
},
|
|
|
|
|
aptPackages: stringArrayField(raw, "aptPackages", `profiles.${id}`),
|
|
|
|
|
dependencyDownload: {
|
|
|
|
|
enabled: booleanField(dependencyDownload, "enabled", `profiles.${id}.dependencyDownload`),
|
|
|
|
|
url: stringField(dependencyDownload, "url", `profiles.${id}.dependencyDownload`),
|
|
|
|
|
chunks: integerField(dependencyDownload, "chunks", `profiles.${id}.dependencyDownload`),
|
|
|
|
|
expectedMiB: integerField(dependencyDownload, "expectedMiB", `profiles.${id}.dependencyDownload`),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runTrans(route: string, script: string, timeoutSeconds: number): CommandResult {
|
|
|
|
|
return runCommand(["/root/.local/bin/trans", route, "sh", "--", script], rootPath(), { timeoutMs: timeoutSeconds * 1000 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shQuote(value: string): string {
|
|
|
|
|
return `'${value.replace(/'/gu, `'\\''`)}'`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function option(args: string[], name: string): string | null {
|
|
|
|
|
const index = args.indexOf(name);
|
|
|
|
|
if (index === -1) return null;
|
|
|
|
|
const value = args[index + 1];
|
|
|
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function positiveIntOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
|
|
|
|
const raw = option(args, name);
|
|
|
|
|
if (raw === null) return defaultValue;
|
|
|
|
|
const value = Number.parseInt(raw, 10);
|
|
|
|
|
if (!Number.isInteger(value) || value < 0 || value > maxValue) throw new Error(`${name} must be an integer from 0 to ${maxValue}`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseJson(textValue: string): unknown {
|
|
|
|
|
const trimmed = textValue.trim();
|
|
|
|
|
if (trimmed.length === 0) return null;
|
|
|
|
|
try { return JSON.parse(trimmed); } catch {
|
|
|
|
|
const start = trimmed.indexOf("{");
|
|
|
|
|
const end = trimmed.lastIndexOf("}");
|
|
|
|
|
if (start >= 0 && end > start) {
|
|
|
|
|
try { return JSON.parse(trimmed.slice(start, end + 1)); } catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
|
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${path}.${key} must be a non-negative integer`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function integerArrayField(obj: Record<string, unknown>, key: string, path: string): number[] {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${path}.${key} must be an integer array`);
|
|
|
|
|
return [...value] as number[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be a non-empty string array`);
|
|
|
|
|
return [...value] as string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 render = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
|
|
|
|
return [render(headers), ...rows.map(render)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
|
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function arrayRecords(value: unknown): Array<Record<string, unknown>> {
|
|
|
|
|
return Array.isArray(value) ? value.map(record) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function text(value: unknown, fallback = "-"): string {
|
|
|
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function number(value: unknown): number {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nullableNumber(value: unknown): number | null {
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bytes(value: unknown): string {
|
|
|
|
|
const parsed = number(value);
|
|
|
|
|
if (parsed <= 0) return "0 B";
|
|
|
|
|
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
|
|
|
let scaled = parsed;
|
|
|
|
|
let unit = 0;
|
|
|
|
|
while (scaled >= 1024 && unit < units.length - 1) {
|
|
|
|
|
scaled /= 1024;
|
|
|
|
|
unit += 1;
|
|
|
|
|
}
|
|
|
|
|
return `${scaled >= 10 || unit === 0 ? scaled.toFixed(0) : scaled.toFixed(1)} ${units[unit]}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rate(value: unknown): string {
|
|
|
|
|
return `${bytes(value)}/s`;
|
|
|
|
|
}
|