1
0
Fork 0
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:
Cursor Agent 2026-06-26 02:19:49 +00:00
parent 6ad1a227c0
commit 982d8857e7
No known key found for this signature in database
5 changed files with 413 additions and 0 deletions

View 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.
![Screenshot of the game](docs/screenshot.png)
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
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View 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"
}

View 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);

View 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);
});