mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-08 07:44:57 +00:00
Add dev server, README, screenshot, and headless smoke tool
- server.js: zero-dependency static file server (Node built-ins only) with correct ES-module MIME types, path-traversal guard, and auto port fallback. - package.json: type=module, start/serve/test/smoke scripts, no dependencies. - README: how to run/play, the scheduling constraints, scoring formula, scenarios, project layout, and testing. - tools/browser-smoke.mjs: optional end-to-end check that drives the real UI in headless Chrome over the DevTools Protocol (Node global fetch/WebSocket, no npm deps); verifies zero console errors, click-to-schedule, and screenshots. - docs/screenshot.png: gameplay screenshot for the README. Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
6ad1a227c0
commit
982d8857e7
5 changed files with 413 additions and 0 deletions
146
k8s-scheduler-game/README.md
Normal file
146
k8s-scheduler-game/README.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# ☸ kubescheduler — the game
|
||||
|
||||
A browser game where **you are the Kubernetes scheduler _and_ the cluster operator**.
|
||||
|
||||
Pods stream into the cluster and you must bind each one to a node that satisfies all of
|
||||
its constraints — resource requests, `nodeSelector`s, taints/tolerations, and pod
|
||||
anti-affinity — while deciding when to **provision new nodes** and when to **cordon, drain,
|
||||
and terminate** the ones you no longer need. Your score rewards low **scheduling latency**
|
||||
and high **utilization**, and punishes wasted spend, SLA breaches, and disruptive evictions.
|
||||
|
||||

|
||||
|
||||
It is written in **zero-dependency vanilla ES modules** (no framework, no build step) and
|
||||
ships with a tiny built-in static server, so it runs anywhere Node is installed.
|
||||
|
||||
---
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
cd k8s-scheduler-game
|
||||
node server.js # serves http://localhost:8080 (set PORT to change)
|
||||
# or: npm start
|
||||
```
|
||||
|
||||
Then open the printed URL. No `npm install` required — there are no dependencies.
|
||||
|
||||
> Prefer Python? `python3 -m http.server 8080` works too. (A static server is needed
|
||||
> because the game uses native ES modules, which browsers won't load over `file://`.)
|
||||
|
||||
---
|
||||
|
||||
## How to play
|
||||
|
||||
1. **Click** a pending pod in the right-hand queue to select it. Every node that can host it
|
||||
lights up **green**; nodes that can't are dimmed and show the **blocking reason** (e.g.
|
||||
_insufficient memory_, _untolerated taint_, _didn't match selector `disktype=ssd`_).
|
||||
2. **Click a green node** — or **drag the pod onto it** — to bind it.
|
||||
3. Or press **⚡** on a pod to auto-place just that one on its best-fit node.
|
||||
|
||||
Keep the queue empty, keep nodes busy, and don't overspend.
|
||||
|
||||
### The constraints (the same ones real `kube-scheduler` enforces)
|
||||
|
||||
| Constraint | What it means in the game |
|
||||
| --- | --- |
|
||||
| **Resource requests** | A pod's CPU / memory / GPU requests must fit in the node's free capacity. |
|
||||
| **nodeSelector** | The node must carry the required label, e.g. `disktype=ssd`, `accelerator=nvidia-t4`. |
|
||||
| **Taints & tolerations** | A node's `NoSchedule` taint (`nvidia.com/gpu`, `spot`) blocks pods that don't tolerate it. |
|
||||
| **Pod anti-affinity (hard)** | Two replicas of the same app may **not** share a node (used by `postgres`). |
|
||||
| **Pod anti-affinity (soft / spread)** | The scheduler _prefers_ to spread replicas across nodes (used by `frontend`/`api`); influences scoring, never blocks. |
|
||||
| **Cordon / not-ready** | Cordoned or still-booting nodes won't accept new pods. |
|
||||
|
||||
### Operating the cluster
|
||||
|
||||
- **➕ Add node** — provision capacity from a node pool. New nodes spend time **Provisioning**
|
||||
(and cost money the whole time) before they go **Ready**.
|
||||
- **Cordon** — mark a node unschedulable without disturbing its running pods.
|
||||
- **Drain** — cordon **and** gracefully evict its pods back to the queue (small penalty).
|
||||
- **Delete** — terminate a node. Any pods still on it are **force-killed** (big penalty) — so
|
||||
drain first!
|
||||
|
||||
Flip on **Auto-schedule** and **Cluster autoscaler** to watch a baseline policy play the game,
|
||||
then turn them off and try to beat it by hand.
|
||||
|
||||
---
|
||||
|
||||
## Scoring
|
||||
|
||||
Each tick you **earn** for cluster **utilization** and **lose** for **pending pods**
|
||||
(scheduling latency pressure) and **node cost**:
|
||||
|
||||
```
|
||||
score += utilization × 30
|
||||
− pendingPods × 1.5
|
||||
− (Σ node $/hr) × 0.6
|
||||
```
|
||||
|
||||
Plus one-off effects: finishing a job **+8**, a graceful drain eviction **−2/pod**, a forced
|
||||
kill **−10/pod**, and an SLA breach (a pod left pending too long) **−40**.
|
||||
|
||||
The headline KPIs in the top bar are the ones the prompt asks you to optimize:
|
||||
|
||||
- **Utilization** — average CPU+memory packing across the nodes you're paying for.
|
||||
- **Avg latency** — mean time pods waited in `Pending` before being scheduled.
|
||||
- …alongside pending/running counts, ready/total nodes, hourly cost, and SLA breaches.
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
| Scenario | Description |
|
||||
| --- | --- |
|
||||
| **Steady State** | Balanced, predictable workload. Good for learning. |
|
||||
| **Traffic Spike** | Calm baseline punctuated by big `frontend`/`api` surges — scale up fast, scale down after. |
|
||||
| **GPU Crunch** | Steady services plus periodic ML training jobs that demand GPU nodes you must provision. |
|
||||
| **Production Chaos** | Everything at once, high churn across every workload type. Hard mode. |
|
||||
|
||||
Workloads are minted from `Deployment`-like templates with bounded replica counts (so the
|
||||
cluster reaches a steady state), and the arrival stream is seeded for reproducible runs.
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
k8s-scheduler-game/
|
||||
├── index.html # markup + KPI/toolbar/grid/queue/log scaffolding
|
||||
├── styles.css # dark "control-plane" dashboard theme
|
||||
├── server.js # zero-dependency static dev server (Node built-ins only)
|
||||
├── src/
|
||||
│ ├── types.js # instance types, app templates, units & helpers
|
||||
│ ├── scheduler.js # predicate filtering + scoring (pure, unit-tested)
|
||||
│ ├── workload.js # seeded PRNG, scenarios, pod generator
|
||||
│ ├── engine.js # game state, tick loop, node lifecycle, scoring, autopilot
|
||||
│ ├── ui.js # DOM rendering + click/drag interaction
|
||||
│ └── main.js # boots the engine + UI and drives the clock
|
||||
├── test/
|
||||
│ └── scheduler.test.mjs # unit + soak + balance tests (node --test)
|
||||
└── tools/
|
||||
└── browser-smoke.mjs # optional headless-Chrome end-to-end check
|
||||
```
|
||||
|
||||
The simulation core (`types`, `scheduler`, `workload`, `engine`) is completely UI-agnostic and
|
||||
pure where it counts, which is why it can be unit-tested under Node without a browser.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # node --test — predicate, engine, soak & balance tests (no browser, no deps)
|
||||
```
|
||||
|
||||
The suite covers the scheduling predicates (resources, selectors, taints, anti-affinity,
|
||||
cordon), engine actions (schedule/drain/evict/job-completion), the autoscaler, determinism of
|
||||
the workload generator, a long "soak" run that asserts the cluster never overcommits a node,
|
||||
and a balance check that every scenario stays healthy under full automation.
|
||||
|
||||
An optional end-to-end check drives the real UI in headless Chrome (no npm deps — it uses
|
||||
Node's built-in `fetch`/`WebSocket` over the DevTools Protocol):
|
||||
|
||||
```bash
|
||||
node server.js & # start the server
|
||||
APP_URL=http://localhost:8080/ npm run smoke # boot the UI, click-schedule, screenshot
|
||||
```
|
||||
BIN
k8s-scheduler-game/docs/screenshot.png
Normal file
BIN
k8s-scheduler-game/docs/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
18
k8s-scheduler-game/package.json
Normal file
18
k8s-scheduler-game/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "kubescheduler-game",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "A browser game where you play the Kubernetes scheduler: bin-pack pods onto nodes respecting selectors, taints and affinity, and manage node autoscaling for the best latency and utilization.",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"serve": "node server.js",
|
||||
"test": "node --test",
|
||||
"smoke": "node tools/browser-smoke.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": ["kubernetes", "scheduler", "game", "simulation", "bin-packing"],
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
72
k8s-scheduler-game/server.js
Normal file
72
k8s-scheduler-game/server.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env node
|
||||
// Tiny zero-dependency static file server for local development.
|
||||
// node server.js -> serves on http://localhost:8080
|
||||
// PORT=3000 node server.js -> custom port
|
||||
// Only Node built-ins are used, so there is nothing to install.
|
||||
|
||||
import http from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join, normalize, extname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL(".", import.meta.url));
|
||||
const START_PORT = Number(process.env.PORT) || 8080;
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".png": "image/png",
|
||||
".map": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === "/") pathname = "/index.html";
|
||||
|
||||
// Resolve within ROOT and reject path traversal.
|
||||
const filePath = normalize(join(ROOT, pathname));
|
||||
if (!filePath.startsWith(ROOT)) {
|
||||
res.writeHead(403).end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (!info || !info.isFile()) {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" }).end("Not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const body = await readFile(filePath);
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[extname(filePath)] || "application/octet-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
});
|
||||
res.end(body);
|
||||
} catch (err) {
|
||||
res.writeHead(500, { "Content-Type": "text/plain" }).end("Server error: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
function listen(port, attemptsLeft = 10) {
|
||||
server.once("error", (err) => {
|
||||
if (err.code === "EADDRINUSE" && attemptsLeft > 0) {
|
||||
listen(port + 1, attemptsLeft - 1);
|
||||
} else {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
server.listen(port, () => {
|
||||
console.log(`\n ☸ kubescheduler — the game`);
|
||||
console.log(` → http://localhost:${port}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
listen(START_PORT);
|
||||
177
k8s-scheduler-game/tools/browser-smoke.mjs
Normal file
177
k8s-scheduler-game/tools/browser-smoke.mjs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// Optional end-to-end smoke test that drives the real UI in headless Chrome via
|
||||
// the DevTools Protocol — no npm dependencies (uses Node's global fetch +
|
||||
// WebSocket). It verifies the page boots with zero console errors/exceptions,
|
||||
// that click-to-schedule and node actions work, and (optionally) writes a
|
||||
// screenshot.
|
||||
//
|
||||
// Requires a Chrome/Chromium binary on the machine. It is intentionally NOT part
|
||||
// of `npm test` (which stays dependency- and browser-free).
|
||||
//
|
||||
// Usage:
|
||||
// 1) start the dev server: node server.js (defaults to :8080)
|
||||
// 2) run this against it: APP_URL=http://localhost:8080/ node tools/browser-smoke.mjs [screenshot.png]
|
||||
// (or simply: npm run smoke)
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync, existsSync } from "node:fs";
|
||||
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:8080/";
|
||||
const PORT = Number(process.env.CDP_PORT) || 9412;
|
||||
const SHOT = process.argv[2] || null;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
process.env.CHROME_BIN,
|
||||
"/opt/google/chrome/chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
].filter(Boolean);
|
||||
const chromeBin = CHROME_CANDIDATES.find((p) => existsSync(p)) || "google-chrome";
|
||||
|
||||
const chrome = spawn(
|
||||
chromeBin,
|
||||
[
|
||||
"--headless=new",
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
`--user-data-dir=/tmp/kube-smoke-${process.pid}`,
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
"--remote-allow-origins=*",
|
||||
"--window-size=1500,950",
|
||||
APP_URL,
|
||||
],
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
|
||||
const exceptions = [];
|
||||
const consoleErrors = [];
|
||||
|
||||
async function getWsUrl() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const list = await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
|
||||
const page = list.find((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
||||
if (page) return page.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error("could not reach Chrome DevTools — is a Chrome binary installed?");
|
||||
}
|
||||
|
||||
function connect(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.addEventListener("open", () => resolve(api));
|
||||
ws.addEventListener("error", (e) => reject(e.message || "ws error"));
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(typeof ev.data === "string" ? ev.data : ev.data.toString());
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
} else if (msg.method === "Runtime.exceptionThrown") {
|
||||
const d = msg.params.exceptionDetails;
|
||||
exceptions.push(d.exception?.description || d.text || "exception");
|
||||
} else if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
|
||||
consoleErrors.push(msg.params.args.map((a) => a.value ?? a.description ?? "").join(" "));
|
||||
} else if (msg.method === "Log.entryAdded" && msg.params.entry.level === "error") {
|
||||
consoleErrors.push(msg.params.entry.text);
|
||||
}
|
||||
});
|
||||
const api = {
|
||||
send(method, params = {}) {
|
||||
const i = ++id;
|
||||
return new Promise((res) => {
|
||||
pending.set(i, res);
|
||||
ws.send(JSON.stringify({ id: i, method, params }));
|
||||
});
|
||||
},
|
||||
close: () => ws.close(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cdp = await connect(await getWsUrl());
|
||||
await cdp.send("Page.enable");
|
||||
await cdp.send("Runtime.enable");
|
||||
await cdp.send("Log.enable");
|
||||
|
||||
const evaluate = async (expression) => {
|
||||
const r = await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
|
||||
if (r.result?.exceptionDetails) throw new Error("eval threw: " + JSON.stringify(r.result.exceptionDetails));
|
||||
return r.result?.result?.value;
|
||||
};
|
||||
|
||||
await cdp.send("Page.navigate", { url: APP_URL });
|
||||
await sleep(1800);
|
||||
|
||||
const booted = await evaluate("typeof window.__kube !== 'undefined' && !!window.__kube.game");
|
||||
if (!booted) throw new Error("app did not boot (window.__kube missing)");
|
||||
|
||||
// Run an automated cluster for a few seconds.
|
||||
await evaluate(`(() => { const {game}=window.__kube;
|
||||
game.reset('chaos'); game.state.autoScale=true; game.state.autoSchedule=true;
|
||||
game.state.speed=4; game.state.paused=false; return 'ok'; })()`);
|
||||
await sleep(8000);
|
||||
|
||||
const stats = JSON.parse(
|
||||
await evaluate(`(() => { const {game}=window.__kube; return JSON.stringify({
|
||||
nodeCards: document.querySelectorAll('.node').length,
|
||||
pending: game.state.pendingIds.length,
|
||||
running: game.runningCount(),
|
||||
util: Math.round(game.clusterUtilization()*100),
|
||||
}); })()`)
|
||||
);
|
||||
console.log("stats:", stats);
|
||||
|
||||
if (SHOT) {
|
||||
const shot = await cdp.send("Page.captureScreenshot", { format: "png" });
|
||||
writeFileSync(SHOT, Buffer.from(shot.result.data, "base64"));
|
||||
console.log("screenshot ->", SHOT);
|
||||
}
|
||||
|
||||
// Manual scheduling path.
|
||||
const manual = JSON.parse(
|
||||
await evaluate(`(() => { const {game,ui}=window.__kube;
|
||||
game.state.paused=true; game.state.autoSchedule=false;
|
||||
for (let i=0;i<22;i++) game.tick(); ui.markDirty(); ui.render();
|
||||
const before = game.state.pendingIds.length;
|
||||
const pod = document.querySelector('.pod'); if(!pod) return JSON.stringify({skipped:true});
|
||||
pod.click(); ui.render();
|
||||
const feasible = document.querySelector('.node.feasible');
|
||||
if (feasible) feasible.click(); ui.render();
|
||||
return JSON.stringify({ before, after: game.state.pendingIds.length,
|
||||
sawFeasible: !!feasible, sawInfeasible: !!document.querySelector('.node.infeasible') }); })()`)
|
||||
);
|
||||
console.log("manual:", manual);
|
||||
|
||||
// Node ops shouldn't throw.
|
||||
await evaluate(`(() => { const {game,ui}=window.__kube; const n=game.state.nodes[0];
|
||||
if(n){ game.cordon(n.id); game.drain(n.id); } ui.markDirty(); ui.render(); return 'ok'; })()`);
|
||||
|
||||
await sleep(200);
|
||||
cdp.close();
|
||||
chrome.kill("SIGKILL");
|
||||
|
||||
const problems = [];
|
||||
if (exceptions.length) problems.push(`exceptions: ${exceptions.join(" | ")}`);
|
||||
if (consoleErrors.length) problems.push(`console errors: ${consoleErrors.join(" | ")}`);
|
||||
if (stats.nodeCards === 0) problems.push("no node cards rendered");
|
||||
if (!manual.skipped && !manual.sawFeasible) problems.push("feasibility highlight missing");
|
||||
|
||||
if (problems.length) {
|
||||
console.error("SMOKE FAILED:\n - " + problems.join("\n - "));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("SMOKE OK — no exceptions, UI interactive.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("SMOKE ERROR:", e.message || e);
|
||||
chrome.kill("SIGKILL");
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue