mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
74b662d8de
Stand up a minikube-backed simulation subsystem for benching Windmill under realistic multi-node load, with a per-bench measurement pipeline and a dashboard renderer that consolidates throughput, queue depth, per-node CPU, PG latency/conns, OOM events, and per-node CPU-util-vs-oversaturation into one SVG report. Sim infrastructure (sim/): - k8s_provisioner: minikube up + heterogeneous node sizing from topology JSON - helm_deploy: helm install Windmill with smoke.yaml + local.yaml overlays - image_cache: pre-load required images so bench bringup is offline-safe - toxiproxy_k8s: per-node toxiproxy DaemonSet for cross-node latency injection - cpu_sampler_k8s: privileged DS reading per-cgroup cpu.stat at 10Hz, dual- writes to stdout AND a host-mounted log file (/var/log/wm-sim-cpu-sampler/ sampler.tsv) so heavy benches no longer lose early samples to kubelet log rotation - pg_logging: ALTER SYSTEM + SIGHUP to enable verbose PG logging without restart - pgbadger: post-bench PG log analysis HTML report - readiness: pre-bench cluster health check (samplers stable ≥30s, workers ready, PG responsive, queue empty, **deploy.status rollout-complete**) — the rollout-complete check catches mid-rolling-update fires that previously starved m04's sampler under cgroup_mutex contention Per-bench JSONL pollers, started/finalized alongside the bench loop: - pod_timeline: 1Hz workers-per-node Ready counts (used for the workers panel) - oom_poller: live OOM event capture (kernel + kubelet evictions + cgroup) - pg_latency_poller: 4Hz psql \\timing on SELECT 1 vs kubectl-exec roundtrip - pg_conn_poller: 1Hz pg_stat_activity by state (active/idle/idle_in_xact) - node_load_poller: 2Hz /proc/loadavg + /proc/stat procs_running per node Dashboard renderer (sim/render_report.ts + graph.ts): - Util group: one panel per node with translucent orange oversaturation area BEHIND solid blue CPU-util area, 100% reference line, phase-boundary verticals. cols:2 grid wraps after 2 panels per row. - PG node tinted with [PG] flag in legend across the dashboard. - Phase-boundary verticals + push-window shaded zones layered consistently. - All x-axes switched from wall-clock HH:MM to relative seconds-from-bench- start. Shared origin sourced from meta.json's bench_start_ms so 0s on every panel = the same wall-clock moment (previously each chart picked its own earliest sample as origin, causing drift between panels). Oversaturation metric, with explicit fallback: - Primary: (procs_running - ncpu) / ncpu × 100 — true CPU run-queue pressure. - Fallback to load1 when procs_running is missing (older reports). - load1 overcounted previously because it includes uninterruptible D-state procs (PG backends in disk I/O, cgroup_mutex waits), inflating "saturation" by 5-10x under load. - Pure helper extracted to sim/util_metrics.ts; 8 unit tests cover the procs_running > load1 preference, the clamp-at-zero, invalid-ncpu cases. Sampler reliability: - HostPath log file in addition to stdout so the bench's scp-based collector bypasses kubelet log rotation entirely. - main.ts truncates the host log file on every node before pushers start (parallel ssh, best-effort) so it doesn't grow unbounded across runs. - Collector falls back to kubectl-logs when scp fails for any node. Workloads (workloads/): - io_4phase: four-phase IO step (idle → 2.5s → 500ms → 150ms jobs) - io_150ms_flood / io_300ms_flood / io_1s_flood / io_2s_flood: single-phase flood configs to isolate the worker-host CFS context-switch storm vs PG contention regime - burst, ops_day, cpu_*, etc. for other scenarios Tests: - sim/util_metrics_test.ts — 8 cases for computeOversatPct - sim/util_panel_snapshot_test.ts — 5 assertions guarding util-panel SVG invariants (orange behind blue, 100% ref line, relative-time ticks NOT wall-clock, phase-boundary verticals, shared-origin override) Helm values: - sim/values/smoke.yaml — bench-tuned: workers w/ no CPU limit & low mem request, PG w/ 3-core request + wm-critical priorityClass + oomImmune + maxConnections, app w/ wm-critical + oomImmune + no resource limits. - sim/values/local.example.yaml — template for the gitignored local.yaml that carries the EE license key. - Depends on the wm-critical PriorityClass + oomImmune + maxConnections knobs landing in windmill-helm-charts (separate PR). graph.ts additions: - areaFills param: ordered list of per-kind translucent area fills drawn before lines, used by the util panel for orange-behind-blue layering - lineColorOverrides: pin per-kind line colors so oversaturation reliably renders orange regardless of d3 ordinal-color insertion order - highlightKindToken: substring-match flag for the PG-node tint in Node CPU - xRelativeOriginMs: shared bench-start origin for the relative-time x-axis - DataPointMulti is now exported for downstream tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
244 lines
9.9 KiB
TypeScript
244 lines
9.9 KiB
TypeScript
// Helm deploy of Windmill onto a provisioned minikube cluster, + API URL
|
|
// resolution via port-forward. Part of the k8s sim path (task #49).
|
|
//
|
|
// Chart source (`chart`):
|
|
// - omitted -> the public remote chart "windmill/windmill"
|
|
// (repo auto-added)
|
|
// - a local path -> used directly (e.g. ../windmill-helm-charts/charts/windmill)
|
|
// - "windmill/<name>" -> remote; the windmill repo is auto-added
|
|
// - any other "repo/x" -> used as-is (assumes the repo is already configured)
|
|
//
|
|
// PG: the chart bundles Postgres (postgresql.enabled, via cloudnative-pg). API:
|
|
// the chart exposes a ClusterIP Service `windmill-app` on port 8000; we
|
|
// port-forward it to localhost so the existing bench can hit it over http.
|
|
|
|
import { parse as yamlParse } from "https://deno.land/std@0.224.0/yaml/mod.ts";
|
|
|
|
const HELM = Deno.env.get("SIM_HELM_BIN") ?? "helm";
|
|
const MINIKUBE = Deno.env.get("SIM_MINIKUBE_BIN") ?? "minikube";
|
|
const WINDMILL_REPO = "https://windmill-labs.github.io/windmill-helm-charts/";
|
|
const APP_SERVICE = "windmill-app";
|
|
const APP_PORT = 8000;
|
|
|
|
export type HelmDeployOptions = {
|
|
profile: string; // minikube profile == kube context
|
|
chart?: string; // local path or "repo/name"; default = vendored chart at
|
|
// benchmarks/sim/charts/windmill (forked from upstream
|
|
// 4.0.169 with `priorityClassName`, PG `maxConnections`,
|
|
// and `priorityClasses.classes[]` exposed via values).
|
|
release?: string; // default "wm"
|
|
namespace?: string; // default "default"
|
|
set?: string[]; // extra --set key=value
|
|
valuesFiles?: string[]; // extra -f values.yaml
|
|
timeoutSec?: number; // helm --wait timeout; default 600
|
|
};
|
|
|
|
// minikube needs the kvm2 driver dir on PATH + LD_LIBRARY_PATH for libvirt
|
|
// (see k8s_provisioner.ts). `minikube kubectl` inherits it; helm doesn't need it.
|
|
function minikubeEnv(): Record<string, string> {
|
|
const base = Deno.env.toObject();
|
|
const driverDir = Deno.env.get("SIM_KVM2_DRIVER_DIR");
|
|
const libDir = Deno.env.get("SIM_LIBVIRT_LIB_DIR");
|
|
if (driverDir) base.PATH = `${driverDir}:${base.PATH ?? ""}`;
|
|
if (libDir) base.LD_LIBRARY_PATH = base.LD_LIBRARY_PATH ? `${libDir}:${base.LD_LIBRARY_PATH}` : libDir;
|
|
return base;
|
|
}
|
|
|
|
async function run(
|
|
bin: string,
|
|
args: string[],
|
|
{ check = true, env }: { check?: boolean; env?: Record<string, string> } = {},
|
|
): Promise<{ stdout: string; stderr: string; code: number }> {
|
|
const p = new Deno.Command(bin, { args, env, stdout: "piped", stderr: "piped" });
|
|
const { code, stdout, stderr } = await p.output();
|
|
const out = new TextDecoder().decode(stdout);
|
|
const err = new TextDecoder().decode(stderr);
|
|
if (check && code !== 0) {
|
|
throw new Error(`${bin} ${args.join(" ")} failed (code ${code}):\n${err || out}`);
|
|
}
|
|
return { stdout: out, stderr: err, code };
|
|
}
|
|
|
|
async function pathExists(p: string): Promise<boolean> {
|
|
try {
|
|
await Deno.stat(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function pickFreePort(): Promise<number> {
|
|
const l = Deno.listen({ port: 0 });
|
|
const port = (l.addr as Deno.NetAddr).port;
|
|
l.close();
|
|
return port;
|
|
}
|
|
|
|
// Parse a local chart's Chart.yaml, register each dep's helm repo. Idempotent
|
|
// (`helm repo add --force-update` overwrites). Skips oci:// URLs (helm fetches
|
|
// those directly without `repo add`).
|
|
async function registerLocalChartDepRepos(chartDir: string): Promise<void> {
|
|
let raw: string;
|
|
try {
|
|
raw = await Deno.readTextFile(`${chartDir}/Chart.yaml`);
|
|
} catch (e) {
|
|
console.warn(`[helm] could not read ${chartDir}/Chart.yaml: ${(e as Error).message}`);
|
|
return;
|
|
}
|
|
let parsed: unknown;
|
|
try { parsed = yamlParse(raw); } catch (e) {
|
|
console.warn(`[helm] Chart.yaml parse failed: ${(e as Error).message}`);
|
|
return;
|
|
}
|
|
const deps = (parsed as { dependencies?: Array<{ name?: string; repository?: string }> })
|
|
.dependencies ?? [];
|
|
const seen = new Set<string>();
|
|
for (const d of deps) {
|
|
const url = d.repository;
|
|
if (!url) continue;
|
|
if (!/^https?:\/\//i.test(url)) continue;
|
|
if (seen.has(url)) continue;
|
|
seen.add(url);
|
|
// Use the dep's chart name as the repo alias. Helm allows any alias.
|
|
const alias = d.name ?? new URL(url).hostname.replace(/[^a-z0-9]/g, "");
|
|
console.log(`[helm] repo add ${alias} ${url}`);
|
|
await run(HELM, ["repo", "add", alias, url, "--force-update"], { check: false });
|
|
}
|
|
await run(HELM, ["repo", "update"], { check: false });
|
|
}
|
|
|
|
// helm install Windmill (bundled PG on). Waits for the release to be ready.
|
|
export async function helmDeployWindmill(opts: HelmDeployOptions): Promise<void> {
|
|
// Default to the in-repo vendored chart so sim runs are reproducible without
|
|
// depending on upstream chart version drift (we hit 4.0.165 → 4.0.166 silent
|
|
// breakage during this project; the vendored copy is our pinned baseline).
|
|
const chart = opts.chart ?? new URL("./charts/windmill", import.meta.url).pathname;
|
|
const release = opts.release ?? "wm";
|
|
const namespace = opts.namespace ?? "default";
|
|
|
|
const isLocalPath = chart.startsWith(".") || chart.startsWith("/") || (await pathExists(chart));
|
|
if (!isLocalPath && chart.startsWith("windmill/")) {
|
|
// Idempotent: `repo add` errors if it exists with a different URL; --force-update avoids that.
|
|
await run(HELM, ["repo", "add", "windmill", WINDMILL_REPO, "--force-update"], { check: false });
|
|
await run(HELM, ["repo", "update", "windmill"], { check: false });
|
|
}
|
|
if (isLocalPath) {
|
|
// The chart has subchart dependencies declared in Chart.yaml. For a remote
|
|
// chart helm fetches them at pull/install; for a LOCAL chart path we must
|
|
// (a) register each dependency's repository with helm (it errors otherwise
|
|
// with "no repository definition for ..."), and (b) `helm dependency build`
|
|
// to vendor the subcharts into the local chart's ./charts/ dir.
|
|
console.log(`[helm] resolving chart dependencies for local chart at ${chart}`);
|
|
await registerLocalChartDepRepos(chart);
|
|
await run(HELM, ["dependency", "build", chart]);
|
|
}
|
|
|
|
// `upgrade --install`: install when absent, upgrade (reconcile) when
|
|
// present. Required for the cluster-reuse path — bare `install` errors with
|
|
// "cannot re-use a name that is still in use" on a second run.
|
|
//
|
|
// No `--wait`. With many workers the chart's default --wait blocks until
|
|
// EVERY deployment is Ready, which deadlocks if workers crashloop on the
|
|
// default PG max_connections (we bump that AFTER helm in main.ts). The
|
|
// caller waits for PG + windmill-app explicitly via kubectl wait, which is
|
|
// both faster and avoids the worker-readiness lock-step.
|
|
const args = [
|
|
"--kube-context", opts.profile,
|
|
"upgrade", "--install", release, chart,
|
|
"--namespace", namespace, "--create-namespace",
|
|
"--set", "postgresql.enabled=true",
|
|
"--timeout", `${opts.timeoutSec ?? 600}s`,
|
|
];
|
|
for (const s of opts.set ?? []) args.push("--set", s);
|
|
for (const f of opts.valuesFiles ?? []) args.push("-f", f);
|
|
|
|
console.log(`[helm] upgrade --install Windmill (release=${release}, chart=${chart}, ns=${namespace})`);
|
|
await run(HELM, args);
|
|
console.log(`[helm] manifests applied — caller waits for PG + app readiness`);
|
|
}
|
|
|
|
// Wait for the bundled PG (StatefulSet or Deployment) to report Ready. Used
|
|
// after `helmDeployWindmill` so the PG-bound steps that follow (max_conn
|
|
// patch, verbose logging) can run.
|
|
export async function waitPgReady(
|
|
profile: string,
|
|
namespace = "default",
|
|
timeoutSec = 300,
|
|
): Promise<void> {
|
|
// Probe both — only one of these will exist depending on persistence flag.
|
|
for (const ref of ["statefulset/windmill-postgresql", "deployment/windmill-postgresql-demo-app"]) {
|
|
const r = await run(
|
|
MINIKUBE,
|
|
[
|
|
"kubectl", "-p", profile, "--",
|
|
"-n", namespace, "rollout", "status", ref,
|
|
`--timeout=${timeoutSec}s`,
|
|
],
|
|
{ check: false },
|
|
);
|
|
if (r.code === 0) {
|
|
console.log(`[helm] PG ready (${ref})`);
|
|
return;
|
|
}
|
|
}
|
|
throw new Error("[helm] PG not found or not ready (neither statefulset nor deployment)");
|
|
}
|
|
|
|
// Wait for windmill-app Deployment to report at least 1 Ready replica.
|
|
export async function waitWindmillAppReady(
|
|
profile: string,
|
|
namespace = "default",
|
|
timeoutSec = 300,
|
|
): Promise<void> {
|
|
const r = await run(
|
|
MINIKUBE,
|
|
[
|
|
"kubectl", "-p", profile, "--",
|
|
"-n", namespace, "rollout", "status", "deployment/windmill-app",
|
|
`--timeout=${timeoutSec}s`,
|
|
],
|
|
{ check: false },
|
|
);
|
|
if (r.code !== 0) {
|
|
throw new Error(`[helm] windmill-app not ready after ${timeoutSec}s`);
|
|
}
|
|
console.log(`[helm] windmill-app ready`);
|
|
}
|
|
|
|
export type ApiEndpoint = { host: string; stop: () => void };
|
|
|
|
// Port-forward svc/windmill-app:8000 to a free localhost port and wait until
|
|
// the API answers. Returns the URL + a stop() to kill the forward.
|
|
export async function portForwardApi(profile: string, namespace = "default"): Promise<ApiEndpoint> {
|
|
const localPort = await pickFreePort();
|
|
const child = new Deno.Command(MINIKUBE, {
|
|
args: [
|
|
"kubectl", "-p", profile, "--",
|
|
"port-forward", "-n", namespace, `svc/${APP_SERVICE}`, `${localPort}:${APP_PORT}`,
|
|
],
|
|
env: minikubeEnv(),
|
|
stdout: "piped",
|
|
stderr: "piped",
|
|
}).spawn();
|
|
|
|
const host = `http://127.0.0.1:${localPort}`;
|
|
const deadline = Date.now() + 60_000;
|
|
let lastErr = "";
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const r = await fetch(`${host}/api/version`, { signal: AbortSignal.timeout(2000) });
|
|
if (r.ok) {
|
|
await r.body?.cancel();
|
|
console.log(`[helm] API reachable at ${host} (port-forward svc/${APP_SERVICE})`);
|
|
return { host, stop: () => { try { child.kill("SIGTERM"); } catch { /* already gone */ } } };
|
|
}
|
|
} catch (e) {
|
|
lastErr = (e as Error).message;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
}
|
|
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
throw new Error(`[helm] port-forward to svc/${APP_SERVICE} never became reachable (last: ${lastErr})`);
|
|
}
|